diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 219825d..7037250 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,3 +13,5 @@ Commit messages must follow this format: - `` 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. diff --git a/README.md b/README.md index dad0e61..2086c94 100644 --- a/README.md +++ b/README.md @@ -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/`:本地服务端客户端专用插件。 diff --git a/bd2w b/bd2w index f0bf812..c1dae79 100755 --- a/bd2w +++ b/bd2w @@ -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" diff --git a/bd2w.cmd b/bd2w.cmd index e9eee98..6ac15ea 100644 --- a/bd2w.cmd +++ b/bd2w.cmd @@ -1,43 +1,33 @@ @echo off setlocal DisableDelayedExpansion set "BD2W_ROOT=%~dp0" +if /I "%~1"=="check-csharp" goto check_csharp set "BD2W_TOOL_DIR=%BD2W_ROOT%.build\tools" set "BD2W_TOOL=%BD2W_TOOL_DIR%\bd2w.exe" -if exist "%BD2W_TOOL%" goto run - -if not exist "%BD2W_TOOL_DIR%" mkdir "%BD2W_TOOL_DIR%" -if errorlevel 1 exit /b 1 +if not exist "%BD2W_TOOL_DIR%" ( + mkdir "%BD2W_TOOL_DIR%" + if errorlevel 1 exit /b 1 +) set "BD2W_LOCK=%BD2W_TOOL_DIR%\.bootstrap.lock" set "BD2W_ATTEMPTS=0" :lock -if exist "%BD2W_TOOL%" goto run mkdir "%BD2W_LOCK%" 2>nul if errorlevel 1 goto wait set "BD2W_TEMP=%BD2W_TOOL_DIR%\.bd2w-%RANDOM%-%RANDOM%.exe" -if exist "%BD2W_TOOL%" goto unlock -pushd "%BD2W_ROOT%go" +call :compile_tool if errorlevel 1 goto failed -set "BD2W_PUSHED=1" -echo bd2w: compiling the build tool for first use... -set "BD2W_PREVIOUS_CGO=%CGO_ENABLED%" -set "GOCACHE=%BD2W_ROOT%go\.cache\go-build" -set "GOOS=" -set "GOARCH=" -for /f "delims=" %%A in ('go env GOHOSTOS') do set "GOOS=%%A" -for /f "delims=" %%A in ('go env GOHOSTARCH') do set "GOARCH=%%A" -if not defined GOOS goto failed -if not defined GOARCH goto failed -set "CGO_ENABLED=0" -go build -buildvcs=false -trimpath -o "%BD2W_TEMP%" ./build +if not exist "%BD2W_TOOL%" goto replace_tool +fc /b "%BD2W_TEMP%" "%BD2W_TOOL%" >nul 2>nul +if errorlevel 1 goto replace_tool +del /q "%BD2W_TEMP%" if errorlevel 1 goto failed +goto unlock +:replace_tool move /y "%BD2W_TEMP%" "%BD2W_TOOL%" >nul if errorlevel 1 goto failed :unlock -if defined BD2W_PUSHED set "CGO_ENABLED=%BD2W_PREVIOUS_CGO%" -if defined BD2W_PUSHED popd rmdir "%BD2W_LOCK%" if errorlevel 1 exit /b 1 -:run "%BD2W_TOOL%" %* exit /b %errorlevel% @@ -51,8 +41,62 @@ ping -n 2 127.0.0.1 >nul goto lock :failed -if defined BD2W_PUSHED popd if defined BD2W_TEMP if exist "%BD2W_TEMP%" del /q "%BD2W_TEMP%" rmdir "%BD2W_LOCK%" echo bd2w: Build tool compilation failed. 1>&2 exit /b 1 + +:compile_tool +setlocal +pushd "%BD2W_ROOT%go" +if errorlevel 1 exit /b 1 +set "GOCACHE=%BD2W_ROOT%go\.cache\go-build" +set "GOOS=" +set "GOARCH=" +for /f "delims=" %%A in ('go env GOHOSTOS') do set "GOOS=%%A" +for /f "delims=" %%A in ('go env GOHOSTARCH') do set "GOARCH=%%A" +if not defined GOOS goto compile_failed +if not defined GOARCH goto compile_failed +set "CGO_ENABLED=0" +go build -buildvcs=false -trimpath -o "%BD2W_TEMP%" ./build +if errorlevel 1 goto compile_failed +popd +exit /b 0 +:compile_failed +popd +exit /b 1 + +:check_csharp +shift +set "BD2W_PROJECTS=" +:check_arguments +if "%~1"=="" goto check_start +set "BD2W_PROJECT=" +for %%P in (GameSdk GameNames LocalIdentity LoginUI CashShop CaptureEnvironment) do if "%~1"=="%%P" set "BD2W_PROJECT=%%P" +if not defined BD2W_PROJECT ( + echo bd2w: Unknown C# project: "%~1" 1>&2 + exit /b 1 +) +set "BD2W_PROJECTS=%BD2W_PROJECTS% %BD2W_PROJECT%" +shift +goto check_arguments +:check_start +if not defined BD2W_PROJECTS set "BD2W_PROJECTS=GameSdk GameNames LocalIdentity LoginUI CashShop CaptureEnvironment" +pushd "%BD2W_ROOT%" +if errorlevel 1 exit /b 1 +set "Configuration=Release" +set "BD2W_FAILED=0" +for %%P in (%BD2W_PROJECTS%) do call :check_project %%P +popd +exit /b %BD2W_FAILED% + +:check_project +echo Checking %~1 +call dotnet build "%BD2W_ROOT%plugins\%~1\%~1.csproj" -c Release -t:Rebuild --nologo -warnaserror +if errorlevel 1 ( + set "BD2W_FAILED=1" + exit /b 0 +) +call dotnet format style "%BD2W_ROOT%plugins\%~1\%~1.csproj" --verify-no-changes --severity info --no-restore +if errorlevel 1 set "BD2W_FAILED=1" +exit /b 0 diff --git a/go/build/build.go b/go/build/build.go index 5a561dd..ad9d8bf 100644 --- a/go/build/build.go +++ b/go/build/build.go @@ -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) -} diff --git a/go/build/config.go b/go/build/config.go new file mode 100644 index 0000000..a383d00 --- /dev/null +++ b/go/build/config.go @@ -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 +} diff --git a/go/build/files.go b/go/build/files.go new file mode 100644 index 0000000..0b92e78 --- /dev/null +++ b/go/build/files.go @@ -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) +} diff --git a/go/build/platform.go b/go/build/platform.go new file mode 100644 index 0000000..3df15f5 --- /dev/null +++ b/go/build/platform.go @@ -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 +} diff --git a/go/build/process.go b/go/build/process.go new file mode 100644 index 0000000..2ada378 --- /dev/null +++ b/go/build/process.go @@ -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 +} diff --git a/go/build/release.go b/go/build/release.go new file mode 100644 index 0000000..2b11a1a --- /dev/null +++ b/go/build/release.go @@ -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 +} diff --git a/go/build/sdk.go b/go/build/sdk.go new file mode 100644 index 0000000..d8dc6fb --- /dev/null +++ b/go/build/sdk.go @@ -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 := `` + if cache != "" { + config += `` + } + config += `` + 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) +} diff --git a/go/build/sdk_pack.go b/go/build/sdk_pack.go new file mode 100644 index 0000000..0d939da --- /dev/null +++ b/go/build/sdk_pack.go @@ -0,0 +1,119 @@ +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 + } + if err = command(toolDLL, "verify-runtime", table, filepath.Join(runtimeBin, "BD2.GameNames.dll")); 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 +} diff --git a/go/build/sdk_verify.go b/go/build/sdk_verify.go new file mode 100644 index 0000000..2057e20 --- /dev/null +++ b/go/build/sdk_verify.go @@ -0,0 +1,169 @@ +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(`(]*>)`) + if !packageReference.Match(raw) { + return fmt.Errorf("sample has no BD2.GameSdk PackageReference") + } + text := packageReference.ReplaceAllString(string(raw), `${1}`+version+`${2}`) + text = regexp.MustCompile(`[^<]*`).ReplaceAllString(text, ""+gameVersion+"") + 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("\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 } + runtime := filepath.Join(output, "BD2.GameNames.dll") + plugin := filepath.Join(output, "ExamplePlugin.dll") + assembly := filepath.Join(managed, "Assembly-CSharp.dll") + if err = command(tool, "verify-runtime", table, runtime); err != nil { + return err + } + 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 +} diff --git a/go/build/version_source.go b/go/build/version_source.go new file mode 100644 index 0000000..a722f21 --- /dev/null +++ b/go/build/version_source.go @@ -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) +} diff --git a/go/build/version_source_test.go b/go/build/version_source_test.go new file mode 100644 index 0000000..1f0e8d7 --- /dev/null +++ b/go/build/version_source_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func versionSourceFixture(t *testing.T) map[string]any { + t.Helper() + return map[string]any{ + "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": map[string]any{"local_identity": "0.6.1", "capture_environment": "0.2.0", "login_ui": "0.2.0", "cash_shop": "0.1.2"}, + } +} + +func writeVersionFixture(t *testing.T, path string, config map[string]any) { + t.Helper() + raw, err := json.Marshal(config) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } +} + +func TestGenerateVersionSourcePreservesUnchangedFile(t *testing.T) { + dir := t.TempDir() + config := filepath.Join(dir, "versions with spaces.json") + output := filepath.Join(dir, "generated with spaces", "Version.cs") + writeVersionFixture(t, config, versionSourceFixture(t)) + args := []string{"--config", config, "--output", output, "--plugin", "cash_shop"} + if err := (task{root: dir}).generateVersionSource(args); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `Plugin = "0.1.2";`) || !strings.Contains(string(raw), `ClientRelease = "2.35.10+client.0.1.0";`) { + t.Fatalf("incorrect generated versions: %s", raw) + } + stamp := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) + if err := os.Chtimes(output, stamp, stamp); err != nil { + t.Fatal(err) + } + if err := (task{root: dir}).generateVersionSource(args); err != nil { + t.Fatal(err) + } + info, err := os.Stat(output) + if err != nil || !info.ModTime().Equal(stamp) { + t.Fatalf("unchanged source rewritten: info=%v err=%v", info, err) + } + changed := versionSourceFixture(t) + changed["plugins"].(map[string]any)["cash_shop"] = "0.1.3" + writeVersionFixture(t, config, changed) + if err := (task{root: dir}).generateVersionSource(args); err != nil { + t.Fatal(err) + } + raw, err = os.ReadFile(output) + if err != nil || !strings.Contains(string(raw), `Plugin = "0.1.3";`) { + t.Fatalf("changed source not installed: %s err=%v", raw, err) + } +} + +func TestGenerateVersionSourceRejectsInvalidConfigWithoutWriting(t *testing.T) { + for _, test := range []struct { + name string + change func(map[string]any) + plugin string + }{ + {"cross-game-client", func(c map[string]any) { c["client_version"] = "2.34.13+client.0.1.0" }, "login_ui"}, + {"wrong-server-component", func(c map[string]any) { c["server_version"] = "2.35.10+client.0.1.0" }, "login_ui"}, + {"invalid-resource", func(c map[string]any) { c["bundle_version"] = "2026092113523" }, "login_ui"}, + {"injected-plugin-source", func(c map[string]any) { c["plugins"].(map[string]any)["cash_shop"] = "1.0.0\";" }, "cash_shop"}, + {"invalid-unselected-plugin", func(c map[string]any) { c["plugins"].(map[string]any)["cash_shop"] = "invalid" }, "login_ui"}, + {"unknown-field", func(c map[string]any) { c["game_versoin"] = "2.35.10" }, "login_ui"}, + {"escaping-seed", func(c map[string]any) { c["seed_directory"] = `..\outside` }, "login_ui"}, + {"unknown-plugin", func(map[string]any) {}, "game_names"}, + } { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + config := filepath.Join(dir, "versions.json") + output := filepath.Join(dir, "Version.cs") + c := versionSourceFixture(t) + test.change(c) + writeVersionFixture(t, config, c) + prior := []byte("existing generated source") + if err := os.WriteFile(output, prior, 0o600); err != nil { + t.Fatal(err) + } + if err := (task{}).generateVersionSource([]string{"--config", config, "--output", output, "--plugin", test.plugin}); err == nil { + t.Fatal("invalid configuration accepted") + } + raw, err := os.ReadFile(output) + if err != nil || string(raw) != string(prior) { + t.Fatalf("validation failure modified output=%s err=%v", raw, err) + } + }) + } +} diff --git a/go/cmd/bd2client/dev_command.go b/go/cmd/bd2client/dev_command.go index 40f91e3..b300afd 100644 --- a/go/cmd/bd2client/dev_command.go +++ b/go/cmd/bd2client/dev_command.go @@ -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") } diff --git a/go/cmd/bd2client/main.go b/go/cmd/bd2client/main.go index b9fdecb..8dd1fbc 100644 --- a/go/cmd/bd2client/main.go +++ b/go/cmd/bd2client/main.go @@ -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 diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index 0f7ff2c..bda7893 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -289,14 +289,14 @@ func serve(args []string) (serveErr error) { if err != nil { return fmt.Errorf("open account state database: %w", err) } - defer stateRepository.Close() + defer func() { _ = stateRepository.Close() }() var authService *auth.Service if authentication.Mode == "oauth" { authStore, err := auth.Open(filepath.Join(filepath.Dir(filepath.Clean(*stateFile)), "auth.db"), authRuntime.MasterKey) if err != nil { return fmt.Errorf("open authentication database: %w", err) } - defer authStore.Close() + defer func() { _ = authStore.Close() }() authService, err = auth.New(authRuntime, authStore) if err != nil { return err @@ -1041,7 +1041,9 @@ func serve(args []string) (serveErr error) { } loginPasses.SetClock(time.Now, eventAPReset.ResetSeconds-9*3600) eventTasksService.AttachUnlockResolver(missionUnlocked) - missionService.AttachEventHandler(eventTasksService) + if err = missionService.AttachEventHandler(eventTasksService); err != nil { + return fmt.Errorf("attach mission event handler: %w", err) + } if err = missionService.RecordLogin(missionUnlocked); err != nil { return err } diff --git a/go/cmd/bd2server/state.go b/go/cmd/bd2server/state.go index 7f43a5a..47694a7 100644 --- a/go/cmd/bd2server/state.go +++ b/go/cmd/bd2server/state.go @@ -26,7 +26,7 @@ func stateCheckCommand(args []string) error { if err != nil { return err } - defer repository.Close() + defer func() { _ = repository.Close() }() version, err := repository.SchemaVersion() if err != nil { return err diff --git a/go/env.ps1 b/go/env.ps1 deleted file mode 100644 index f194ee1..0000000 --- a/go/env.ps1 +++ /dev/null @@ -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' diff --git a/go/internal/client/app/app.go b/go/internal/client/app/app.go index 4d08a75..b378c65 100644 --- a/go/internal/client/app/app.go +++ b/go/internal/client/app/app.go @@ -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" diff --git a/go/internal/client/app/desktop.go b/go/internal/client/app/desktop.go index 7e729ae..e42a437 100644 --- a/go/internal/client/app/desktop.go +++ b/go/internal/client/app/desktop.go @@ -45,7 +45,7 @@ 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, diff --git a/go/internal/client/app/platform_windows.go b/go/internal/client/app/platform_windows.go index 1e477a9..811c2ac 100644 --- a/go/internal/client/app/platform_windows.go +++ b/go/internal/client/app/platform_windows.go @@ -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 @@ -74,7 +77,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 +98,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 +127,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 +148,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 +158,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 } diff --git a/go/internal/client/config/config.go b/go/internal/client/config/config.go index ad0fc0d..1a00612 100644 --- a/go/internal/client/config/config.go +++ b/go/internal/client/config/config.go @@ -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) } diff --git a/go/internal/client/config/preferences.go b/go/internal/client/config/preferences.go index db5edce..6c13352 100644 --- a/go/internal/client/config/preferences.go +++ b/go/internal/client/config/preferences.go @@ -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) } diff --git a/go/internal/client/introdb/cipher.go b/go/internal/client/introdb/cipher.go index 916df17..463faa8 100644 --- a/go/internal/client/introdb/cipher.go +++ b/go/internal/client/introdb/cipher.go @@ -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) } diff --git a/go/internal/client/introdb/introdb.go b/go/internal/client/introdb/introdb.go index 657a89b..96eb68f 100644 --- a/go/internal/client/introdb/introdb.go +++ b/go/internal/client/introdb/introdb.go @@ -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 diff --git a/go/internal/client/plugin/install.go b/go/internal/client/plugin/install.go index 71923f9..a951c5a 100644 --- a/go/internal/client/plugin/install.go +++ b/go/internal/client/plugin/install.go @@ -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() } diff --git a/go/internal/client/setup/local_catalog.go b/go/internal/client/setup/local_catalog.go index a32329d..3a5739c 100644 --- a/go/internal/client/setup/local_catalog.go +++ b/go/internal/client/setup/local_catalog.go @@ -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) @@ -163,8 +163,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) @@ -288,7 +287,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) } diff --git a/go/internal/client/setup/setup.go b/go/internal/client/setup/setup.go index 09f6b0a..c4726a8 100644 --- a/go/internal/client/setup/setup.go +++ b/go/internal/client/setup/setup.go @@ -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 { diff --git a/go/internal/server/account/portrait_test.go b/go/internal/server/account/portrait_test.go index edce6c2..704eb8e 100644 --- a/go/internal/server/account/portrait_test.go +++ b/go/internal/server/account/portrait_test.go @@ -33,7 +33,11 @@ func TestLoginPortraitUsesSQLiteSelectionAfterRestart(t *testing.T) { if err != nil { t.Fatal(err) } - defer r.Close() + defer func() { + if err := r.Close(); err != nil { + t.Error(err) + } + }() d, err = deck.OpenStore(r, seed) if err != nil { t.Fatal(err) diff --git a/go/internal/server/accountstate/atomic_entry.go b/go/internal/server/accountstate/atomic_entry.go index e266f37..4e3e81a 100644 --- a/go/internal/server/accountstate/atomic_entry.go +++ b/go/internal/server/accountstate/atomic_entry.go @@ -27,7 +27,7 @@ func (r *Repository) SaveWithEntries(domain string, core []byte, changes []state if err != nil { return err } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() if err := saveWithEntries(tx, domain, core, changes); err != nil { return err } diff --git a/go/internal/server/accountstate/bootstrap.go b/go/internal/server/accountstate/bootstrap.go index 15d5b5b..e269fa9 100644 --- a/go/internal/server/accountstate/bootstrap.go +++ b/go/internal/server/accountstate/bootstrap.go @@ -40,11 +40,15 @@ func (r *Repository) InitializationState(accountDomains ...string) (Initializati for rows.Next() { var name string if err := rows.Scan(&name); err != nil { - rows.Close() + _ = rows.Close() return InitializationCorrupt, err } found = append(found, name) } + if err := rows.Err(); err != nil { + _ = rows.Close() + return InitializationCorrupt, err + } if err := rows.Close(); err != nil { return InitializationCorrupt, err } diff --git a/go/internal/server/accountstate/bootstrap_test.go b/go/internal/server/accountstate/bootstrap_test.go index 309670c..8374525 100644 --- a/go/internal/server/accountstate/bootstrap_test.go +++ b/go/internal/server/accountstate/bootstrap_test.go @@ -38,7 +38,11 @@ func TestInitializationMarkerMakesSchemaOnlyRollbackRetryable(t *testing.T) { if err != nil { t.Fatal(err) } - defer repository.Close() + defer func() { + if err := repository.Close(); err != nil { + t.Error(err) + } + }() if repository.IsNew() { t.Fatal("schema-only retry incorrectly depends on file creation") } @@ -76,7 +80,11 @@ func TestInitializationMarkerCommitsCompleteAccountExactlyOnce(t *testing.T) { if err != nil { t.Fatal(err) } - defer repository.Close() + defer func() { + if err := repository.Close(); err != nil { + t.Error(err) + } + }() if state, err := repository.InitializationState("mail", "wallet"); err != nil || state != InitializationComplete { t.Fatalf("committed state=%d err=%v", state, err) } @@ -87,7 +95,11 @@ func TestInitializationStateRejectsPartialAccount(t *testing.T) { if err != nil { t.Fatal(err) } - defer repository.Close() + defer func() { + if err := repository.Close(); err != nil { + t.Error(err) + } + }() if err := repository.Save("mail", []byte(`{"version":1}`)); err != nil { t.Fatal(err) } diff --git a/go/internal/server/accountstate/entry.go b/go/internal/server/accountstate/entry.go index 57ed460..0784971 100644 --- a/go/internal/server/accountstate/entry.go +++ b/go/internal/server/accountstate/entry.go @@ -65,7 +65,7 @@ func (t *Tx) ListEntries(domain, bucket string) (map[string][]byte, error) { if err != nil { return nil, fmt.Errorf("accountstate: list entries: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() entries := make(map[string][]byte) for rows.Next() { var key string @@ -144,7 +144,7 @@ func (r *Repository) LoadEntry(domain, bucket, key string) ([]byte, bool, error) if err != nil { return nil, false, err } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() return tx.LoadEntry(domain, bucket, key) } @@ -160,7 +160,7 @@ func (r *Repository) ListEntries(domain, bucket string) (map[string][]byte, erro if err != nil { return nil, err } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() return tx.ListEntries(domain, bucket) } @@ -176,7 +176,7 @@ func (r *Repository) PutEntry(domain, bucket, key string, payload []byte) error if err != nil { return err } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() if err := tx.PutEntry(domain, bucket, key, payload); err != nil { return err } @@ -195,7 +195,7 @@ func (r *Repository) DeleteEntry(domain, bucket, key string) (bool, error) { if err != nil { return false, err } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() deleted, err := tx.DeleteEntry(domain, bucket, key) if err != nil { return false, err diff --git a/go/internal/server/accountstate/entry_test.go b/go/internal/server/accountstate/entry_test.go index f827253..08e3101 100644 --- a/go/internal/server/accountstate/entry_test.go +++ b/go/internal/server/accountstate/entry_test.go @@ -147,7 +147,11 @@ func TestOldSchemaIsRejectedWithoutMutation(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() 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) diff --git a/go/internal/server/accountstate/repository.go b/go/internal/server/accountstate/repository.go index a4af9a9..0ab7fd0 100644 --- a/go/internal/server/accountstate/repository.go +++ b/go/internal/server/accountstate/repository.go @@ -107,7 +107,7 @@ func claimWriterEpoch(ctx context.Context, db *sql.DB) (int64, error) { if err != nil { return 0, fmt.Errorf("accountstate: begin writer claim: %w", err) } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() var raw string if err := tx.QueryRowContext(ctx, `SELECT value FROM metadata WHERE key='writer_epoch'`).Scan(&raw); err != nil { return 0, fmt.Errorf("accountstate: read writer epoch: %w", err) @@ -148,7 +148,7 @@ func (r *Repository) RequireDomains(required ...string) error { if err != nil { return fmt.Errorf("accountstate: list domains: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() var found []string for rows.Next() { var name string @@ -225,7 +225,7 @@ func (r *Repository) LoadContext(ctx context.Context, name string) ([]byte, int6 if err != nil { return nil, 0, false, err } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() return tx.Load(name) } @@ -236,7 +236,7 @@ func (r *Repository) SaveContext(ctx context.Context, name string, payload []byt if err != nil { return 0, err } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() generation, err := tx.Save(name, payload) if err != nil { return 0, err diff --git a/go/internal/server/accountstate/repository_test.go b/go/internal/server/accountstate/repository_test.go index c43bbf4..743b917 100644 --- a/go/internal/server/accountstate/repository_test.go +++ b/go/internal/server/accountstate/repository_test.go @@ -94,7 +94,11 @@ func TestRollbackAndReopen(t *testing.T) { if err != nil { t.Fatal(err) } - defer reopened.Close() + defer func() { + if err := reopened.Close(); err != nil { + t.Error(err) + } + }() requireState(t, reopened, "wallet", []byte("committed"), 2) var mode string if err := reopened.db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil || mode != "wal" { @@ -241,7 +245,11 @@ func TestSecondRepositoryIsRejectedUntilWriterCloses(t *testing.T) { if err != nil { t.Fatalf("Open after writer Close: %v", err) } - defer second.Close() + defer func() { + if err := second.Close(); err != nil { + t.Error(err) + } + }() } func TestWriterEpochStillFencesStaleRepositoryBeforeMutation(t *testing.T) { @@ -333,7 +341,11 @@ func BenchmarkRequestTransaction(b *testing.B) { if err != nil { b.Fatal(err) } - defer r.Close() + defer func() { + if err := r.Close(); err != nil { + b.Error(err) + } + }() ctx := context.Background() payload := bytes.Repeat([]byte("x"), 4096) b.ResetTimer() diff --git a/go/internal/server/accountstate/schema.go b/go/internal/server/accountstate/schema.go index 6986121..15115df 100644 --- a/go/internal/server/accountstate/schema.go +++ b/go/internal/server/accountstate/schema.go @@ -36,8 +36,7 @@ func initialize(ctx context.Context, db *sql.DB, fresh bool) error { if err != nil { return fmt.Errorf("accountstate: begin schema transaction: %w", err) } - defer tx.Rollback() - + defer func() { _ = tx.Rollback() }() if fresh { if err := createV1(ctx, tx); err != nil { return err @@ -178,11 +177,15 @@ func validateSchemaTables(tx *sql.Tx, version int) error { for rows.Next() { var name string if err := rows.Scan(&name); err != nil { - rows.Close() + _ = rows.Close() return err } names = append(names, name) } + if err := rows.Err(); err != nil { + _ = rows.Close() + return err + } if err := rows.Close(); err != nil { return err } @@ -222,11 +225,15 @@ func tableColumns(tx *sql.Tx, table string) ([]schemaColumn, error) { var entry schemaColumn var defaultValue any if err := columns.Scan(&ordinal, &entry.name, &entry.kind, &entry.notNull, &defaultValue, &entry.pk); err != nil { - columns.Close() + _ = columns.Close() return nil, err } actual = append(actual, entry) } + if err := columns.Err(); err != nil { + _ = columns.Close() + return nil, err + } if err := columns.Close(); err != nil { return nil, err } diff --git a/go/internal/server/accountstate/schema_test.go b/go/internal/server/accountstate/schema_test.go index 8316c96..04a9e78 100644 --- a/go/internal/server/accountstate/schema_test.go +++ b/go/internal/server/accountstate/schema_test.go @@ -15,7 +15,11 @@ func createV1Database(t *testing.T, path string, domain, payload string) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() tx, err := db.Begin() if err != nil { t.Fatal(err) @@ -52,12 +56,16 @@ func TestMigrationV1ToV2IsRepeatSafe(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() tx, err := db.Begin() if err != nil { t.Fatal(err) } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() for range 2 { if err := migrateV1ToV2(context.Background(), tx); err != nil { t.Fatal(err) @@ -75,12 +83,16 @@ func TestMigrationV2ToV3IsRepeatSafe(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() tx, err := db.Begin() if err != nil { t.Fatal(err) } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() if err := migrateV1ToV2(context.Background(), tx); err != nil { t.Fatal(err) } @@ -102,7 +114,11 @@ func TestOpenMigratesV1ToCurrent(t *testing.T) { if err != nil { t.Fatal(err) } - defer r.Close() + defer func() { + if err := r.Close(); err != nil { + t.Error(err) + } + }() version, err := r.SchemaVersion() if err != nil || version != schemaVersion { t.Fatalf("schema version %d, error %v", version, err) @@ -116,7 +132,11 @@ func TestMigrationsRejectMissingAndNonAdjacentSteps(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() for _, test := range []struct { name string steps []migration @@ -130,7 +150,7 @@ func TestMigrationsRejectMissingAndNonAdjacentSteps(t *testing.T) { if err != nil { t.Fatal(err) } - defer tx.Rollback() + defer func() { _ = 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) @@ -143,14 +163,20 @@ 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() + if err := r.Close(); err != nil { + t.Error(err) + } t.Fatal("opened state rejected by final validation") } db, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() 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) @@ -168,7 +194,11 @@ func TestMigrationFailureRollsBackEarlierStepWork(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() tx, err := db.Begin() if err != nil { t.Fatal(err) diff --git a/go/internal/server/accountstate/server_policy_test.go b/go/internal/server/accountstate/server_policy_test.go index 401fa7a..3af4ded 100644 --- a/go/internal/server/accountstate/server_policy_test.go +++ b/go/internal/server/accountstate/server_policy_test.go @@ -29,7 +29,11 @@ func TestStartingPackPolicyPersistsAndRejectsChanges(t *testing.T) { if err != nil { t.Fatal(err) } - defer r.Close() + defer func() { + if err := r.Close(); err != nil { + t.Error(err) + } + }() op, err = r.BeginOperation() if err != nil { t.Fatal(err) @@ -72,7 +76,11 @@ func TestStartingPackPolicyRollbackAllowsAnotherChoice(t *testing.T) { if err != nil { t.Fatal(err) } - defer r.Close() + defer func() { + if err := r.Close(); err != nil { + t.Error(err) + } + }() op, err = r.BeginOperation() if err != nil { t.Fatal(err) @@ -94,7 +102,7 @@ func TestStartingPackPolicyMissingExistingRejectsInitialization(t *testing.T) { if err != nil { t.Fatal(err) } - defer op.Rollback() + defer func() { _ = 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) @@ -113,7 +121,7 @@ func TestStartingPackPolicyRejectsInvalidValues(t *testing.T) { if err != nil { t.Fatal(err) } - defer op.Rollback() + defer func() { _ = op.Rollback() }() if _, err := r.LockStartingPack(1, false); err == nil { t.Fatal("accepted invalid stored value") } @@ -124,7 +132,7 @@ func TestStartingPackPolicyRejectsInvalidValues(t *testing.T) { if err != nil { t.Fatal(err) } - defer op.Rollback() + defer func() { _ = 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) diff --git a/go/internal/server/accountstate/validate.go b/go/internal/server/accountstate/validate.go index 3c12fbb..d886ba7 100644 --- a/go/internal/server/accountstate/validate.go +++ b/go/internal/server/accountstate/validate.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "sort" "strconv" "strings" @@ -131,7 +132,7 @@ func duplicateIndexProblems(code string, path []string, values []uint64) []Probl duplicates = append(duplicates, value) } } - sort.Slice(duplicates, func(i, j int) bool { return duplicates[i] < duplicates[j] }) + slices.Sort(duplicates) problems := make([]Problem, 0, len(duplicates)) for _, value := range duplicates { problems = append(problems, Problem{Code: code, Path: path, Message: "duplicate inventory identity", RelatedIDs: []uint64{value}}) @@ -206,7 +207,7 @@ func (r *Repository) Validate() ([]Problem, error) { if err != nil { return nil, err } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() return tx.Validate() } @@ -313,7 +314,7 @@ func indexedEntries(tx *sql.Tx, domain, bucket string, readUseChar bool) ([]uint if err != nil { return nil, nil, fmt.Errorf("accountstate: list %s.%s for validation: %w", domain, bucket, err) } - defer rows.Close() + defer func() { _ = rows.Close() }() var indices []uint64 var users []namedIndex for rows.Next() { @@ -349,7 +350,7 @@ func namedUintEntries(tx *sql.Tx, domain, bucket string) ([]namedIndex, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() var entries []namedIndex for rows.Next() { var entry namedIndex @@ -370,7 +371,7 @@ func boolEntries(tx *sql.Tx, domain, bucket string) (map[string]bool, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() entries := make(map[string]bool) for rows.Next() { var key string @@ -391,7 +392,7 @@ func indexedGrantEntries(tx *sql.Tx) ([]indexedGrant, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() var grants []indexedGrant for rows.Next() { var grant indexedGrant diff --git a/go/internal/server/auth/service.go b/go/internal/server/auth/service.go index 06eb4b7..1935e98 100644 --- a/go/internal/server/auth/service.go +++ b/go/internal/server/auth/service.go @@ -92,7 +92,7 @@ func securityHeaders(next http.Handler) http.Handler { } func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool { - defer r.Body.Close() + defer func() { _ = r.Body.Close() }() data, err := io.ReadAll(io.LimitReader(r.Body, 16<<10+1)) if err != nil || len(data) > 16<<10 { http.Error(w, "request too large", http.StatusRequestEntityTooLarge) @@ -157,7 +157,7 @@ func (s *Service) createDevice(w http.ResponseWriter, r *http.Request) { http.Error(w, "could not create transaction", http.StatusInternalServerError) return } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() if err := cleanupExpired(tx, now.Unix()); err != nil { http.Error(w, "could not create transaction", http.StatusInternalServerError) return @@ -396,7 +396,7 @@ func (s *Service) exchangeIdentity(ctx context.Context, provider, code, verifier if err != nil { return providerIdentity{}, networkProviderFailure(ctx, provider, "token_exchange", err) } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() if response.StatusCode != http.StatusOK { return providerIdentity{}, rejectedProviderFailure(provider, "token_exchange", response) } @@ -422,7 +422,7 @@ func (s *Service) exchangeIdentity(ctx context.Context, provider, code, verifier if err != nil { return providerIdentity{}, networkProviderFailure(ctx, provider, "userinfo", err) } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() if response.StatusCode != http.StatusOK { return providerIdentity{}, rejectedProviderFailure(provider, "userinfo", response) } @@ -449,7 +449,7 @@ func (s *Service) verifyGoogleIDToken(ctx context.Context, idToken, nonce string if err != nil { return providerIdentity{}, networkProviderFailure(ctx, "google", "id_token_verify", err) } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() if response.StatusCode != http.StatusOK { return providerIdentity{}, rejectedProviderFailure("google", "id_token_verify", response) } @@ -499,7 +499,7 @@ func (s *Service) completeDevice(deviceID, provider string, identity providerIde if err != nil { return err } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() var accountID, status string subjectHash := s.store.identityDigest(identity.issuer, identity.subject) err = tx.QueryRow(`SELECT i.account_id,a.status FROM identities i JOIN accounts a ON a.id=i.account_id WHERE i.issuer=? AND i.subject_hash=?`, identity.issuer, subjectHash).Scan(&accountID, &status) @@ -646,7 +646,7 @@ func (s *Service) poll(w http.ResponseWriter, r *http.Request) { http.Error(w, "login result unavailable", http.StatusInternalServerError) return } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() var storedHash, sealed []byte var status, errorCode string var expires int64 @@ -773,7 +773,7 @@ func (s *Service) refresh(w http.ResponseWriter, r *http.Request) { http.Error(w, "refresh unavailable", http.StatusInternalServerError) return } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() if err := cleanupExpired(tx, now.Unix()); err != nil { http.Error(w, "refresh unavailable", http.StatusInternalServerError) return @@ -914,14 +914,8 @@ func refreshAttemptSealID(familyID string, attemptHash []byte) string { } func (r refreshAttemptResult) deviceResult(now int64) deviceResult { - accessTTL := r.AccessExpiresAt - now - if accessTTL < 0 { - accessTTL = 0 - } - refreshTTL := r.RefreshExpiresAt - now - if refreshTTL < 0 { - refreshTTL = 0 - } + accessTTL := max(r.AccessExpiresAt-now, 0) + refreshTTL := max(r.RefreshExpiresAt-now, 0) return deviceResult{ Provider: r.Provider, AccessToken: r.AccessToken, AccessExpiresIn: accessTTL, RefreshToken: r.RefreshToken, RefreshExpiresIn: refreshTTL, @@ -945,7 +939,7 @@ func (s *Service) revoke(w http.ResponseWriter, r *http.Request) { http.Error(w, "revocation unavailable", http.StatusInternalServerError) return } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() var familyID, accountStatus string var expires int64 var revoked sql.NullInt64 diff --git a/go/internal/server/auth/service_test.go b/go/internal/server/auth/service_test.go index e2a2455..91bea3a 100644 --- a/go/internal/server/auth/service_test.go +++ b/go/internal/server/auth/service_test.go @@ -401,7 +401,7 @@ func TestJSONLimitsAndSecurityHeaders(t *testing.T) { func TestCreateDeviceLimitsPendingTransactionsPerClient(t *testing.T) { service, store := testService(t) handler := service.Handler() - for i := 0; i < 5; i++ { + for i := range 5 { response := postJSON(handler, "/auth/device", map[string]string{"provider": "discord"}) if response.Code != http.StatusCreated { t.Fatalf("create %d status=%d body=%q", i, response.Code, response.Body.String()) @@ -434,7 +434,7 @@ func TestDecodeProviderJSONRejectsOversizeAndTrailingValues(t *testing.T) { func TestRequestLimiterIsBoundedAndExpiresWindows(t *testing.T) { limiter := requestLimiter{windows: make(map[string]limitWindow)} now := time.Unix(testNowUnix, 0) - for i := 0; i < 4096; i++ { + for i := range 4096 { if !limiter.allow(strconv.Itoa(i), now, time.Minute, 1) { t.Fatalf("rejected window %d before capacity", i) } diff --git a/go/internal/server/auth/store.go b/go/internal/server/auth/store.go index 62a9c1d..0783dbc 100644 --- a/go/internal/server/auth/store.go +++ b/go/internal/server/auth/store.go @@ -64,7 +64,7 @@ func Open(path string, masterKey []byte) (*Store, error) { if err != nil { return nil, err } - defer tx.Rollback() + defer func() { _ = tx.Rollback() }() statements := []string{ `CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL) WITHOUT ROWID`, `CREATE TABLE IF NOT EXISTS accounts (id TEXT PRIMARY KEY NOT NULL, status TEXT NOT NULL, created_at INTEGER NOT NULL, last_login_at INTEGER NOT NULL) WITHOUT ROWID`, diff --git a/go/internal/server/auth/store_test.go b/go/internal/server/auth/store_test.go index 9667181..5bb2410 100644 --- a/go/internal/server/auth/store_test.go +++ b/go/internal/server/auth/store_test.go @@ -16,7 +16,11 @@ func TestStoreRequiresAndClearsExactMasterKey(t *testing.T) { if err != nil { t.Fatal(err) } - defer store.Close() + defer func() { + if err := store.Close(); err != nil { + t.Error(err) + } + }() for index, value := range key { if value != 0 { t.Fatalf("master key byte %d was retained by the caller buffer", index) @@ -38,7 +42,9 @@ func TestStoreMigratesSchemaV1ToV2(t *testing.T) { t.Fatal(err) } if _, err := db.Exec(`DROP TABLE refresh_attempts; UPDATE metadata SET value='1' WHERE key='schema_version'`); err != nil { - db.Close() + if err := db.Close(); err != nil { + t.Error(err) + } t.Fatal(err) } if err := db.Close(); err != nil { @@ -48,7 +54,11 @@ func TestStoreMigratesSchemaV1ToV2(t *testing.T) { if err != nil { t.Fatal(err) } - defer reopened.Close() + defer func() { + if err := reopened.Close(); err != nil { + t.Error(err) + } + }() var version int if err := reopened.db.QueryRow(`SELECT CAST(value AS INTEGER) FROM metadata WHERE key='schema_version'`).Scan(&version); err != nil { t.Fatal(err) diff --git a/go/internal/server/authconfig/config.go b/go/internal/server/authconfig/config.go index 26ac7af..eb374ad 100644 --- a/go/internal/server/authconfig/config.go +++ b/go/internal/server/authconfig/config.go @@ -25,7 +25,7 @@ type Config struct { PublicURL string `json:"public_url,omitempty"` MasterKeyEnv string `json:"master_key_env,omitempty"` Providers map[string]ProviderConfig `json:"providers,omitempty"` - Session SessionConfig `json:"session,omitempty"` + Session SessionConfig `json:"session"` } type ProviderConfig struct { @@ -68,7 +68,7 @@ func Load(path string) (Config, error) { if err != nil { return Config{}, fmt.Errorf("authconfig: open %s: %w", path, err) } - defer file.Close() + defer func() { _ = file.Close() }() var config Config decoder := json.NewDecoder(file) decoder.DisallowUnknownFields() @@ -113,7 +113,7 @@ func (c Config) Validate() error { return errors.New("public_url must be an absolute origin without path, query, fragment, or user info") } localhost := publicURL.Hostname() == "127.0.0.1" || publicURL.Hostname() == "localhost" || publicURL.Hostname() == "::1" - if publicURL.Scheme != "https" && !(localhost && publicURL.Scheme == "http") { + if publicURL.Scheme != "https" && !(localhost && publicURL.Scheme == "http") { //nolint:staticcheck // QF1001 return errors.New("public_url must use HTTPS except on localhost") } for name, provider := range c.Providers { diff --git a/go/internal/server/battle/battle.go b/go/internal/server/battle/battle.go index c63202a..b44daab 100644 --- a/go/internal/server/battle/battle.go +++ b/go/internal/server/battle/battle.go @@ -405,7 +405,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) if field.Type != 2 { return nil } - if field.Number == 4 { + if field.Number == 4 { //nolint:staticcheck // QF1003 response = wire.AppendBytes(response, 1, field.Value) } else if field.Number == 5 { response = wire.AppendBytes(response, 2, field.Value) diff --git a/go/internal/server/battle/battle_test.go b/go/internal/server/battle/battle_test.go index 6b3bd04..3d30ef0 100644 --- a/go/internal/server/battle/battle_test.go +++ b/go/internal/server/battle/battle_test.go @@ -245,7 +245,7 @@ func TestBattleStateIsIsolatedPerGameSession(t *testing.T) { func TestRepeatedCurrentSessionSelectionDoesNotEvictOtherBattles(t *testing.T) { service := NewService("", "", nil, nil) - for index := 0; index < 1024; index++ { + for index := range 1024 { service.BeginSession(fmt.Sprintf("session-%d", index)) } service.BeginSession("session-1023") diff --git a/go/internal/server/battle/field_buff_test.go b/go/internal/server/battle/field_buff_test.go index 732651d..5699810 100644 --- a/go/internal/server/battle/field_buff_test.go +++ b/go/internal/server/battle/field_buff_test.go @@ -31,7 +31,7 @@ func TestFieldBuffConsumptionAfterValidationAndBeforeBattleActivation(t *testing t.Fatal("persistence failure activated battle") } fail = false - for i := 0; i < 2; i++ { + for range 2 { if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil { t.Fatal(err) } diff --git a/go/internal/server/calendar/binary_test.go b/go/internal/server/calendar/binary_test.go index e2dd61b..4c6c80b 100644 --- a/go/internal/server/calendar/binary_test.go +++ b/go/internal/server/calendar/binary_test.go @@ -80,7 +80,7 @@ func TestBinaryRejectsCorruptionLimitsAndTrailing(t *testing.T) { if e != nil { t.Fatal(e) } - for n := 0; n < len(raw); n++ { + for n := range len(raw) { if _, e = UnmarshalBinary(raw[:n]); e == nil { t.Fatalf("accepted truncation %d", n) } diff --git a/go/internal/server/calendar/readonly.go b/go/internal/server/calendar/readonly.go index bd664c6..91551bb 100644 --- a/go/internal/server/calendar/readonly.go +++ b/go/internal/server/calendar/readonly.go @@ -3,6 +3,7 @@ package calendar import ( "bd2server/internal/server/readonly" "fmt" + "maps" ) func scalar(n int, v uint64) readonly.Field { return readonly.Field{Number: n, Type: 0, Varint: v} } @@ -29,9 +30,7 @@ func (s *Set) ApplyReadonly(seed *readonly.Seed) (*readonly.Seed, error) { return nil, fmt.Errorf("calendar: missing calendar/static seed") } result := &readonly.Seed{Version: seed.Version, Responses: make(map[string]readonly.Response, len(seed.Responses))} - for path, response := range seed.Responses { - result.Responses[path] = response - } + maps.Copy(result.Responses, seed.Responses) var hunt []readonly.Field if h := s.MonsterHunt; h != nil { for _, v := range h.Seasons { diff --git a/go/internal/server/commerce/clear_packages_test.go b/go/internal/server/commerce/clear_packages_test.go index ccae56c..a79eb7c 100644 --- a/go/internal/server/commerce/clear_packages_test.go +++ b/go/internal/server/commerce/clear_packages_test.go @@ -84,10 +84,10 @@ func TestClearPackageRejectsConflictingMalformedAndSpoofedRows(t *testing.T) { row = wire.AppendVarint(row, 2, 12) row = wire.AppendVarint(row, 3, 2) prefix := wire.AppendVarint(nil, 1, 1) - row = row[:len(row):len(row)] - prefix = prefix[:len(prefix):len(prefix)] + row = row[:len(row):len(row)] //nolint:modernize // slicesclip + prefix = prefix[:len(prefix):len(prefix)] //nolint:modernize // slicesclip active := wire.AppendBytes(append([]byte(nil), prefix...), 3, row) - active = active[:len(active):len(active)] + active = active[:len(active):len(active)] //nolint:modernize // slicesclip requests := map[string][]byte{ "missing row": prefix, "empty active": wire.AppendBytes(prefix, 3, nil), @@ -269,7 +269,7 @@ func TestClearPackageMailSQLiteAtomicRetryAndReconnect(t *testing.T) { if err := repo.Close(); err != nil { t.Fatal(err) } - repo, s, mailbox, _, _ = open(false) + _, s, mailbox, _, _ = open(false) // A replay must restore the exact response without requiring progress again // or issuing a second mail, including after sequence/session changes. s.AttachProgress(nil, nil) diff --git a/go/internal/server/commerce/delivery_test.go b/go/internal/server/commerce/delivery_test.go index afd2939..cef016d 100644 --- a/go/internal/server/commerce/delivery_test.go +++ b/go/internal/server/commerce/delivery_test.go @@ -139,7 +139,10 @@ func TestCashPurchaseMailSQLiteRollbackRestartBatchAndReplay(t *testing.T) { t.Fatal(err) } } - repo, shop, mailbox, wallet, items := open() + var mailbox *mail.Service + var wallet *player.Wallet + var items *player.Inventory + repo, shop, _, _, _ := open() shop.AttachPurchaseHook(func(string, gamedata.CashProductDesign, uint64) error { return fmt.Errorf("injected failure after issuing mail") }) @@ -222,7 +225,7 @@ func TestCashPurchaseMailSQLiteRollbackRestartBatchAndReplay(t *testing.T) { t.Fatal("purchase replay selected or charged again") } _ = repo.Close() - repo, shop, mailbox, wallet, items = open() + repo, _, mailbox, _, _ = open() claim := wire.AppendVarint(nil, 1, 3) claim = wire.AppendVarint(claim, 2, 1) claim = wire.AppendVarint(claim, 2, 2) @@ -235,7 +238,7 @@ func TestCashPurchaseMailSQLiteRollbackRestartBatchAndReplay(t *testing.T) { } rollback(op) _ = repo.Close() - repo, shop, mailbox, wallet, items = open() + repo, _, mailbox, wallet, items = open() if wallet.Snapshot().Gold != 0 || len(items.All()) != 0 { t.Fatal("claim rollback retained rewards") } @@ -267,7 +270,11 @@ func TestCashPurchaseMailSQLiteRollbackRestartBatchAndReplay(t *testing.T) { } _ = repo.Close() repo, _, mailbox, wallet, items = open() - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() op, err = repo.BeginOperation() if err != nil { t.Fatal(err) diff --git a/go/internal/server/commerce/entitlements.go b/go/internal/server/commerce/entitlements.go index e0bc024..ed2074c 100644 --- a/go/internal/server/commerce/entitlements.go +++ b/go/internal/server/commerce/entitlements.go @@ -126,7 +126,7 @@ func (e *EntitlementEconomy) applyPrepared(identity string, costs, rewards []gam } input := make([]gamedata.BattleReward, len(rewards)) for i, r := range rewards { - input[i] = gamedata.BattleReward{Type: r.Type, ID: r.ID, Count: r.Count} + input[i] = gamedata.BattleReward{Type: r.Type, ID: r.ID, Count: r.Count} //nolint:staticcheck // S1016 } leaves := input if !resolved { @@ -184,10 +184,7 @@ func (e *EntitlementEconomy) applyPrepared(identity string, costs, rewards []gam return nil, fmt.Errorf("commerce: subscription reward schedule missing") } now := e.now().UnixMilli() - expiry := int64(e.items.ContentTicketExpiry(r.ID)) - if expiry < now { - expiry = now - } + expiry := max(int64(e.items.ContentTicketExpiry(r.ID)), now) if r.Count > uint64((math.MaxInt64-expiry)/(30*86400000)) { return nil, fmt.Errorf("commerce: subscription expiry overflow") } @@ -201,7 +198,7 @@ func (e *EntitlementEconomy) applyPrepared(identity string, costs, rewards []gam sub.Expiry = expiry s.Subscriptions[key] = sub default: - regular = append(regular, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + regular = append(regular, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 if r.Type == 19 && e.design.TicketTypes[r.ID] == 3 && len(e.design.Attendance[r.ID]) > 0 { key := strconv.FormatUint(r.ID, 10) if _, exists := s.Subscriptions[key]; !exists { @@ -314,10 +311,7 @@ func (e *EntitlementEconomy) MergeAttendance(response []byte) ([]byte, error) { } if typ := e.design.AttendanceTypes[ticket]; typ != 0 { rewards := []byte{} - n := sub.Claimed - if n > uint64(len(e.design.Attendance[ticket])) { - n = uint64(len(e.design.Attendance[ticket])) - } + n := min(sub.Claimed, uint64(len(e.design.Attendance[ticket]))) for i := uint64(1); i <= n; i++ { rewards = wire.AppendVarint(rewards, 1, i) } diff --git a/go/internal/server/commerce/event_shops_test.go b/go/internal/server/commerce/event_shops_test.go index c766a94..65d47be 100644 --- a/go/internal/server/commerce/event_shops_test.go +++ b/go/internal/server/commerce/event_shops_test.go @@ -124,12 +124,18 @@ func TestNativeEventShopSQLiteRollsBackDebitGrantAndPurchaseCount(t *testing.T) if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { t.Fatal(err) } - repo.Close() + if err := repo.Close(); err != nil { + t.Fatal(err) + } repo, err = accountstate.Open(path) if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() wallet, err = player.OpenWallet(repo, player.Currency{}) if err != nil { t.Fatal(err) diff --git a/go/internal/server/commerce/installed_audit_test.go b/go/internal/server/commerce/installed_audit_test.go index 6bd6927..6cb2b45 100644 --- a/go/internal/server/commerce/installed_audit_test.go +++ b/go/internal/server/commerce/installed_audit_test.go @@ -66,7 +66,9 @@ func TestInstalledCashProductRewardCoverage23510(t *testing.T) { if err := rowsDB.Err(); err != nil { t.Fatal(err) } - rowsDB.Close() + if err := rowsDB.Close(); err != nil { + t.Fatal(err) + } release() policy, err := NewCatalog("2.35.10", catalog, gameconfig.Default().Purchases) if err != nil { diff --git a/go/internal/server/commerce/login_pass.go b/go/internal/server/commerce/login_pass.go index 1eda7cb..68b73f3 100644 --- a/go/internal/server/commerce/login_pass.go +++ b/go/internal/server/commerce/login_pass.go @@ -6,7 +6,7 @@ import ( "bd2server/internal/server/wire" "encoding/json" "fmt" - "sort" + "slices" "sync" "time" ) @@ -92,7 +92,7 @@ func (s *LoginPasses) ClaimAndInfo(identity string) ([]byte, [][]byte, error) { for group := range s.design.Groups { keys = append(keys, group) } - sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + slices.Sort(keys) r, seen := v.Receipts[identity] if !seen { r = loginPassReceipt{Rewarded: map[uint64]bool{}} diff --git a/go/internal/server/commerce/native_products_test.go b/go/internal/server/commerce/native_products_test.go index 3f93671..00d4e9e 100644 --- a/go/internal/server/commerce/native_products_test.go +++ b/go/internal/server/commerce/native_products_test.go @@ -155,12 +155,18 @@ func TestNativeTotalDiamondPurchaseSQLiteRollbackAndRestartReplay(t *testing.T) if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { t.Fatal(err) } - repo.Close() + if err := repo.Close(); err != nil { + t.Fatal(err) + } repo, err = accountstate.Open(path) if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() wallet, err = player.OpenWallet(repo, player.Currency{}) if err != nil { t.Fatal(err) diff --git a/go/internal/server/commerce/service_test.go b/go/internal/server/commerce/service_test.go index c972820..75f424c 100644 --- a/go/internal/server/commerce/service_test.go +++ b/go/internal/server/commerce/service_test.go @@ -281,7 +281,11 @@ func TestSQLiteAccountTransactionRollsBackCostRewardAndReceipt(t *testing.T) { if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() wallet, err = player.OpenWallet(repo, player.Currency{}) if err != nil { t.Fatal(err) diff --git a/go/internal/server/configfile/default.go b/go/internal/server/configfile/default.go index 06821cd..ec644bf 100644 --- a/go/internal/server/configfile/default.go +++ b/go/internal/server/configfile/default.go @@ -30,7 +30,7 @@ func Ensure(path string, defaults any) error { if err != nil { return err } - defer os.Remove(file.Name()) + defer func() { _ = os.Remove(file.Name()) }() if _, err = file.Write(append(data, '\n')); err == nil { err = file.Sync() } diff --git a/go/internal/server/cryptox/cryptox.go b/go/internal/server/cryptox/cryptox.go index 406601d..c14ee6e 100644 --- a/go/internal/server/cryptox/cryptox.go +++ b/go/internal/server/cryptox/cryptox.go @@ -38,7 +38,7 @@ func SessionKey(userKey string) ([]byte, error) { return nil, fmt.Errorf("%w: got %d bytes", ErrInvalidKey, len(userKey)) } for _, c := range []byte(userKey) { - if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F') { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F') { //nolint:staticcheck // QF1001 return nil, fmt.Errorf("%w: session key is not hexadecimal ASCII", ErrInvalidKey) } } diff --git a/go/internal/server/dbcrypt/pages.go b/go/internal/server/dbcrypt/pages.go index c41f935..51c905b 100644 --- a/go/internal/server/dbcrypt/pages.go +++ b/go/internal/server/dbcrypt/pages.go @@ -28,7 +28,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) } diff --git a/go/internal/server/deck/auto_recovery.go b/go/internal/server/deck/auto_recovery.go index 9e3e84a..8bfd1e4 100644 --- a/go/internal/server/deck/auto_recovery.go +++ b/go/internal/server/deck/auto_recovery.go @@ -110,7 +110,7 @@ func (s *Store) handleAutoRecovery(req []byte) (int, []byte, bool, error) { } return nil } - if n.FieldCharControlDeckType == 0 { + if n.FieldCharControlDeckType == 0 { //nolint:staticcheck // QF1003 for _, v := range n.Deck { if e = add(v.CharacterInvenIndex); e != nil { return fail(e) diff --git a/go/internal/server/deck/auto_recovery_test.go b/go/internal/server/deck/auto_recovery_test.go index ca66028..1f97abd 100644 --- a/go/internal/server/deck/auto_recovery_test.go +++ b/go/internal/server/deck/auto_recovery_test.go @@ -14,9 +14,15 @@ func recoveryFixture(t *testing.T) *presetFixture { if e := f.deck.AttachFieldSettings(&gamedata.FieldSettingsDesign{TalentSlots: 1, CharacterTalentClass: map[uint64]uint64{350: 10, 360: 14}}); e != nil { t.Fatal(e) } - f.characters.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }) - f.characters.SetCurrentHealth(100, 100) - f.characters.SetCurrentHealth(200, 0) + if err := f.characters.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }); err != nil { + t.Fatal(err) + } + if err := f.characters.SetCurrentHealth(100, 100); err != nil { + t.Fatal(err) + } + if err := f.characters.SetCurrentHealth(200, 0); err != nil { + t.Fatal(err) + } if _, _, _, e := f.deck.Handle("/DeckSave", req(1, triple(200, 1, 1))); e != nil { t.Fatal(e) } @@ -34,7 +40,9 @@ func TestAutoRecoveryPersistsResponseAndDisabledSettingNeverExecutes(t *testing. if caster != 100 || len(targets) != 1 || targets[0] != 200 { t.Fatal("wrong actual fatigue targets") } - f.characters.SetCurrentHealth(200, 25) + if err := f.characters.SetCurrentHealth(200, 25); err != nil { + return player.AutoRecoveryResult{}, err + } c, _ := f.characters.Find(200) c.HP = 25 return player.AutoRecoveryResult{Caster: 100, Characters: []player.Character{c}, Experience: 2, Catalyst: 10}, nil @@ -61,8 +69,10 @@ func TestAutoRecoveryPersistsResponseAndDisabledSettingNeverExecutes(t *testing. if _, _, _, e = f.deck.Handle("/CharAutoReviveSet", req(4, wire.AppendVarint(nil, 3, 100))); e != nil { t.Fatal(e) } - f.characters.SetCurrentHealth(200, 0) - _, out, _, e = f.deck.Handle("/DeckCharAutoRevive", req(5, wire.AppendVarint(nil, 2, 100))) + if err := f.characters.SetCurrentHealth(200, 0); err != nil { + t.Fatal(err) + } + _, _, _, e = f.deck.Handle("/DeckCharAutoRevive", req(5, wire.AppendVarint(nil, 2, 100))) if e != nil || calls != 1 { t.Fatal("disabled automatic recovery executed", e) } @@ -88,7 +98,9 @@ func TestAutoRecoveryFailureReportsExhaustionAndStoryCannotRecover(t *testing.T) if e != nil || mode != 3 || disabled != 2 { t.Fatalf("failure mode%d disabled%d %v", mode, disabled, e) } - f.characters.SetCurrentHealth(100, 0) + if err := f.characters.SetCurrentHealth(100, 0); err != nil { + t.Fatal(err) + } f.deck.state.FieldCharControlDeckType = 2 f.deck.AttachAutoRecovery(func(uint64, uint64, []uint64) (player.AutoRecoveryResult, error) { t.Fatal("story mode must not recover temporary party") diff --git a/go/internal/server/deck/costume_use.go b/go/internal/server/deck/costume_use.go index c794d51..6194ad9 100644 --- a/go/internal/server/deck/costume_use.go +++ b/go/internal/server/deck/costume_use.go @@ -3,6 +3,7 @@ package deck import ( "bd2server/internal/server/wire" "fmt" + "maps" "math" ) @@ -59,8 +60,6 @@ func (s *Store) handleCostumeUse(req []byte) (int, []byte, bool, error) { } } n := clone(s.state) - for char, cost := range assignments { - n.Costumes[char] = cost - } + maps.Copy(n.Costumes, assignments) return 41, nil, true, s.commit(n) } diff --git a/go/internal/server/deck/costume_use_test.go b/go/internal/server/deck/costume_use_test.go index 76717bb..bd32fd8 100644 --- a/go/internal/server/deck/costume_use_test.go +++ b/go/internal/server/deck/costume_use_test.go @@ -64,7 +64,11 @@ func TestBatchCostumeUseRestoresCharacterSelectionsFromSQLite(t *testing.T) { if e != nil { t.Fatal(e) } - defer r.Close() + defer func() { + if err := r.Close(); err != nil { + t.Error(err) + } + }() inv, e = player.OpenInventory(r, &player.Starter{Version: "2.35.10"}) if e != nil { t.Fatal(e) diff --git a/go/internal/server/deck/deck.go b/go/internal/server/deck/deck.go index 2fcb12c..f08c79e 100644 --- a/go/internal/server/deck/deck.go +++ b/go/internal/server/deck/deck.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "os" "sync" @@ -247,13 +248,9 @@ func clone(x state) state { y.Waypoints[k] = append([]uint64(nil), v...) } y.Costumes = map[uint64]uint64{} - for k, v := range x.Costumes { - y.Costumes[k] = v - } + maps.Copy(y.Costumes, x.Costumes) y.Packs = map[uint64]uint64{} - for k, v := range x.Packs { - y.Packs[k] = v - } + maps.Copy(y.Packs, x.Packs) return y } func checkSeq(req []byte) error { diff --git a/go/internal/server/deck/field_settings_test.go b/go/internal/server/deck/field_settings_test.go index 059d9b7..1f23473 100644 --- a/go/internal/server/deck/field_settings_test.go +++ b/go/internal/server/deck/field_settings_test.go @@ -55,12 +55,14 @@ func TestTalentSlotsRestoreOwnershipAndClearSlots(t *testing.T) { t.Fatal(e) } n := 0 - wire.Walk(b, func(f wire.Field) error { + if err := wire.Walk(b, func(f wire.Field) error { if f.Number == 2 { n++ } return nil - }) + }); err != nil { + t.Fatal(err) + } if n != 3 { t.Fatal("DeckInfo missing empty quick slot", n) } @@ -85,7 +87,9 @@ func TestCharmFieldAndTalentSlotsExpireWithoutPermanentOwnership(t *testing.T) { } install(uint64(time.Now().Add(time.Hour).UnixMilli())) d := &gamedata.FieldSettingsDesign{TalentSlots: 1, CharacterTalentClass: map[uint64]uint64{9010: 10}, CharacterTemporaryPack: map[uint64]int{9010: 99}} - f.deck.AttachFieldSettingsPack(func() (int, error) { return 4, nil }) + if err := f.deck.AttachFieldSettingsPack(func() (int, error) { return 4, nil }); err != nil { + t.Fatal(err) + } if e := f.deck.AttachFieldSettings(d); e != nil { t.Fatal(e) } @@ -116,7 +120,9 @@ func TestTemporaryFieldAndTalentSlotsFollowPack(t *testing.T) { } pack := 4 d := &gamedata.FieldSettingsDesign{TalentSlots: 1, CharacterTalentClass: map[uint64]uint64{9001: 9}, CharacterTemporaryPack: map[uint64]int{9001: 4}} - f.deck.AttachFieldSettingsPack(func() (int, error) { return pack, nil }) + if err := f.deck.AttachFieldSettingsPack(func() (int, error) { return pack, nil }); err != nil { + t.Fatal(err) + } if e := f.deck.AttachFieldSettings(d); e != nil { t.Fatal(e) } diff --git a/go/internal/server/deck/preset.go b/go/internal/server/deck/preset.go index f8fab00..5cd592c 100644 --- a/go/internal/server/deck/preset.go +++ b/go/internal/server/deck/preset.go @@ -5,6 +5,8 @@ import ( "encoding/json" "errors" "fmt" + "maps" + "slices" "sort" "strconv" "strings" @@ -471,7 +473,7 @@ func (s *Store) handlePresetInfo(request []byte) (int, []byte, bool, error) { slots = append(slots, slot) } } - sort.Slice(slots, func(i, j int) bool { return slots[i] < slots[j] }) + slices.Sort(slots) var response []byte for _, slot := range slots { response = wire.AppendBytes(response, 1, presetWire(s.presets[slot])) @@ -706,9 +708,7 @@ func (s *Store) handlePresetUse(request []byte) (int, []byte, bool, error) { } next := clone(s.state) next.Deck = append([]DeckEntry(nil), deckEntries...) - for character, costume := range assignments { - next.Costumes[character] = costume - } + maps.Copy(next.Costumes, assignments) if err := s.commit(next); err != nil { return 0, nil, true, fmt.Errorf("deck: persist applied preset: %w", err) } @@ -732,7 +732,7 @@ func (s *Store) handlePresetUse(request []byte) (int, []byte, bool, error) { for index := range characterSet { indices = append(indices, index) } - sort.Slice(indices, func(i, j int) bool { return indices[i] < indices[j] }) + slices.Sort(indices) for _, index := range indices { if character, found := s.characters.Find(index); found { response = wire.AppendBytes(response, 2, player.CharacterWire(character)) @@ -841,7 +841,7 @@ func (s *Store) handleCostumeSettingInfo(request []byte) (int, []byte, bool, err for index := range s.costumeSettings { indices = append(indices, index) } - sort.Slice(indices, func(i, j int) bool { return indices[i] < indices[j] }) + slices.Sort(indices) var response []byte for _, index := range indices { response = wire.AppendBytes(response, 1, costumeSettingWire(s.costumeSettings[index])) diff --git a/go/internal/server/deck/preset_test.go b/go/internal/server/deck/preset_test.go index 2907ff4..280182b 100644 --- a/go/internal/server/deck/preset_test.go +++ b/go/internal/server/deck/preset_test.go @@ -117,7 +117,7 @@ func presetRequest(slot, character, costume, equipment uint64, name string) []by base = wire.AppendVarint(base, 3, 1) deck := wire.AppendBytes(nil, 1, base) deck = wire.AppendVarint(deck, 2, costume) - for equipmentType := uint64(0); equipmentType < 5; equipmentType++ { + for equipmentType := range uint64(5) { entry := wire.AppendVarint(nil, 1, equipmentType) if equipmentType == 0 { entry = wire.AppendVarint(entry, 2, equipment) @@ -202,7 +202,7 @@ func TestPresetMetadataCanCreateEmptySlot(t *testing.T) { func TestPresetAddSlotChargesOncePersistsAndCaps(t *testing.T) { f := newPresetFixture(t) add := req(20, wire.AppendVarint(nil, 2, 1)) - for attempt := 0; attempt < 2; attempt++ { + for attempt := range 2 { if code, _, _, err := f.deck.Handle("/PresetAddSlot", add); err != nil || code != 180 { t.Fatalf("add attempt %d code=%d err=%v", attempt, code, err) } diff --git a/go/internal/server/deck/waypoint.go b/go/internal/server/deck/waypoint.go index f398c73..6dbd28f 100644 --- a/go/internal/server/deck/waypoint.go +++ b/go/internal/server/deck/waypoint.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" "math" - "sort" + "slices" "bd2server/internal/server/gamedata" "bd2server/internal/server/wire" @@ -28,12 +28,7 @@ func (s *Store) ActivatedWaypoint(pack, id uint64) bool { return hasWaypoint(s.state.Waypoints[pack], id) } func hasWaypoint(points []uint64, id uint64) bool { - for _, p := range points { - if p == id { - return true - } - } - return false + return slices.Contains(points, id) } func validWaypointState(packs map[uint64][]uint64) error { @@ -75,7 +70,7 @@ func (s *Store) handleWaypoint(path string, req []byte) (int, []byte, bool, erro } if path == "/WaypointInfo" { ids := append([]uint64(nil), s.state.Waypoints[pack]...) - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) var packed []byte for _, id := range ids { if _, known := design.Points[id]; !known { diff --git a/go/internal/server/deck/waypoint_test.go b/go/internal/server/deck/waypoint_test.go index 8dda509..d544f7c 100644 --- a/go/internal/server/deck/waypoint_test.go +++ b/go/internal/server/deck/waypoint_test.go @@ -93,7 +93,7 @@ func TestWaypointTravelChargesOnceAndRejectsInvalidBeforeSpending(t *testing.T) } } valid := waypointReq(6, 1, 1, 2) - for i := 0; i < 2; i++ { + for range 2 { code, _, _, err := s.Handle("/WaypointUse", valid) if err != nil || code != 33 { t.Fatalf("travel %d %v", code, err) diff --git a/go/internal/server/eventactions/battle.go b/go/internal/server/eventactions/battle.go index dda80a6..7d7c63a 100644 --- a/go/internal/server/eventactions/battle.go +++ b/go/internal/server/eventactions/battle.go @@ -124,9 +124,9 @@ func (s *Service) tacticsLines(uid uint64) uint64 { n := uint64(5) - g.V(4) clear := s.state.Tactics[uid] var lines uint64 - for i := uint64(0); i < n; i++ { + for i := range n { row, col := true, true - for j := uint64(0); j < n; j++ { + for j := range n { row = row && contains(clear, i*n+j+1) col = col && contains(clear, j*n+i+1) } @@ -138,7 +138,7 @@ func (s *Service) tacticsLines(uid uint64) uint64 { } } a, b := true, true - for i := uint64(0); i < n; i++ { + for i := range n { a = a && contains(clear, i*n+i+1) b = b && contains(clear, i*n+(n-i)) } diff --git a/go/internal/server/eventactions/cafeteria.go b/go/internal/server/eventactions/cafeteria.go index c4351f8..266161c 100644 --- a/go/internal/server/eventactions/cafeteria.go +++ b/go/internal/server/eventactions/cafeteria.go @@ -39,10 +39,7 @@ func (s *Service) cafeteriaReward(req []byte, identity string) ([]byte, error) { if last := s.state.CafeteriaLast[receiptKey]; last > 0 && now-last < int64(term)*1000 { return nil, errors.New("eventactions: cafeteria interaction cooldown") } - count := row.V(11) - if count > cap-s.state.CafeteriaCurrency { - count = cap - s.state.CafeteriaCurrency - } + count := min(row.V(11), cap-s.state.CafeteriaCurrency) rewards := []gamedata.Reward{{Type: row.V(13), ID: row.V(12), Count: count}} bundle, err := s.economy.Apply(identity, nil, rewards) if err != nil { diff --git a/go/internal/server/eventactions/mini_content.go b/go/internal/server/eventactions/mini_content.go index ee6dc86..217f203 100644 --- a/go/internal/server/eventactions/mini_content.go +++ b/go/internal/server/eventactions/mini_content.go @@ -3,7 +3,7 @@ package eventactions import ( "errors" "fmt" - "sort" + "slices" "strconv" "strings" @@ -78,7 +78,7 @@ func (s *Service) dailyStory(path string, b []byte, identity string) ([]byte, er } } } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) var out []byte for _, id := range ids { out = wire.AppendVarint(out, 1, id) diff --git a/go/internal/server/eventactions/mini_content_test.go b/go/internal/server/eventactions/mini_content_test.go index 403618a..a48a26a 100644 --- a/go/internal/server/eventactions/mini_content_test.go +++ b/go/internal/server/eventactions/mini_content_test.go @@ -65,7 +65,9 @@ func TestMiniContentStoryAndQuizCompleteReplayAndWindows(t *testing.T) { } reopened.now = s.now reopened.BeginSession("test") - reopened.AttachMiniContent(routes, d) + if err := reopened.AttachMiniContent(routes, d); err != nil { + t.Fatal(err) + } _, replay, _, err := reopened.Handle("/NpcQuizClear", quiz) if err != nil || !bytes.Equal(replay, quizReply) || e.calls != 2 { t.Fatal("quiz restart replay", err) diff --git a/go/internal/server/eventactions/service.go b/go/internal/server/eventactions/service.go index 93b7ff5..f3aada3 100644 --- a/go/internal/server/eventactions/service.go +++ b/go/internal/server/eventactions/service.go @@ -13,6 +13,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "sort" "strings" "sync" @@ -544,12 +545,10 @@ func (s *Service) RecordTacticsClear(uid, stage uint64) error { if _, ok = s.row("TacticsBingoTable", 4, 5, group.V(1), stage); !ok { return errors.New("eventactions: invalid tactics stage") } - for _, id := range s.state.Tactics[uid] { - if id == stage { - return nil - } + if slices.Contains(s.state.Tactics[uid], stage) { + return nil } s.state.Tactics[uid] = append(s.state.Tactics[uid], stage) - sort.Slice(s.state.Tactics[uid], func(i, j int) bool { return s.state.Tactics[uid][i] < s.state.Tactics[uid][j] }) + slices.Sort(s.state.Tactics[uid]) return s.save() } diff --git a/go/internal/server/eventactions/spawn_test.go b/go/internal/server/eventactions/spawn_test.go index cb8d44c..7facc13 100644 --- a/go/internal/server/eventactions/spawn_test.go +++ b/go/internal/server/eventactions/spawn_test.go @@ -41,12 +41,18 @@ func TestTrackerRewardAndProgressFailureRollbackTogether(t *testing.T) { if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { t.Fatal(err) } - repo.Close() + if err := repo.Close(); err != nil { + t.Fatal(err) + } repo, err = accountstate.Open(path) if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() b, err := repo.Load("tracker-reward") if err != nil || b != nil { t.Fatal("reward survived transaction rollback") @@ -134,12 +140,14 @@ func TestTrackerPrepareCatchResumeAndCrossSequenceRetry(t *testing.T) { t.Fatal("reconnect lost durable catch progress") } caught := 0 - wire.Walk(p, func(f wire.Field) error { + if err := wire.Walk(p, func(f wire.Field) error { if f.Number == 5 { caught++ } return nil - }) + }); err != nil { + t.Fatal(err) + } if caught != 1 { t.Fatal("resume lost caught monster") } @@ -155,7 +163,9 @@ func TestTrackerSlotsExpiryAndDailyReset(t *testing.T) { s.now = func() time.Time { return now } s.design.Tables["FieldSpawnEventTable"] = append(s.design.Tables["FieldSpawnEventTable"], gamedata.EventActionRow{Values: map[int]uint64{4: 1, 5: 2, 2: 1}, Text: map[int]string{7: "13:00:00", 1: "13:10:00"}}) r := events.NewRegistry() - r.Replace([]events.Schedule{{UID: 3, Type: 21, ID: 1, Start: base.Add(-time.Hour).UnixMilli(), End: base.Add(48 * time.Hour).UnixMilli()}}) + if err := r.Replace([]events.Schedule{{UID: 3, Type: 21, ID: 1, Start: base.Add(-time.Hour).UnixMilli(), End: base.Add(48 * time.Hour).UnixMilli()}}); err != nil { + t.Fatal(err) + } s.registry = r if _, _, _, err := s.Handle("/FieldEventSpawnStart", spawnStart(1, 1)); err != nil { t.Fatal(err) diff --git a/go/internal/server/eventactions/voting.go b/go/internal/server/eventactions/voting.go index 05e4fa7..293da4b 100644 --- a/go/internal/server/eventactions/voting.go +++ b/go/internal/server/eventactions/voting.go @@ -6,6 +6,7 @@ import ( "bd2server/internal/server/wire" "errors" "fmt" + "slices" "sort" "time" ) @@ -41,16 +42,11 @@ func (s *Service) candidates(r gamedata.EventActionRow) []uint64 { ids = append(ids, x.V(2)) } } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) return ids } func contains(ids []uint64, id uint64) bool { - for _, v := range ids { - if v == id { - return true - } - } - return false + return slices.Contains(ids, id) } func voteWire(v *vote) []byte { b := wire.AppendVarint(nil, 1, v.Round) diff --git a/go/internal/server/eventexchange/service.go b/go/internal/server/eventexchange/service.go index b15db6f..7c8b1ee 100644 --- a/go/internal/server/eventexchange/service.go +++ b/go/internal/server/eventexchange/service.go @@ -238,7 +238,7 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ } var rewards []gamedata.Reward var changed []byte - for n := uint64(0); n < count; n++ { + for range count { entries := g.Page(p.Page) var weights []uint64 var sum uint64 @@ -299,7 +299,7 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ } else { rs := make([]gamedata.BattleReward, len(rewards)) for i, r := range rewards { - rs[i] = gamedata.BattleReward{Type: r.Type, ID: r.ID, Count: r.Count} + rs[i] = gamedata.BattleReward{Type: r.Type, ID: r.ID, Count: r.Count} //nolint:staticcheck // S1016 } bundle, err = s.runtime.ConsumeAndGrant("eventexchange:"+key, uses, rs) } diff --git a/go/internal/server/eventgames/games.go b/go/internal/server/eventgames/games.go index 7e0f3b9..80179aa 100644 --- a/go/internal/server/eventgames/games.go +++ b/go/internal/server/eventgames/games.go @@ -14,6 +14,7 @@ import ( "fmt" "math" "math/big" + "slices" "strconv" "strings" "sync" @@ -129,12 +130,7 @@ func nums(b []byte, n int) ([]uint64, error) { } func scalar(b []byte, n int) uint64 { v, _, _ := wire.Varint(b, n); return v } func contains(a []uint64, v uint64) bool { - for _, x := range a { - if x == v { - return true - } - } - return false + return slices.Contains(a, v) } func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { return s.HandleSession(path, req, "local") diff --git a/go/internal/server/eventgames/play.go b/go/internal/server/eventgames/play.go index 98fec81..b1037b8 100644 --- a/go/internal/server/eventgames/play.go +++ b/go/internal/server/eventgames/play.go @@ -48,7 +48,7 @@ func (s *Service) play(path string, req []byte, g *GameState, d *gamedata.EventG return fail(fmt.Errorf("eventgames: invalid bingo play count")) } cost = plays - for i := uint64(0); i < plays; i++ { + for range plays { var available []uint64 for pos := range g.Board { if !contains(g.Opened, uint64(pos)) { @@ -192,7 +192,7 @@ func (s *Service) play(path string, req []byte, g *GameState, d *gamedata.EventG return fail(fmt.Errorf("eventgames: roulette draw exceeds limit")) } } - for i := uint64(0); i < n; i++ { + for range n { force := d.Pity > 0 && g.SinceSpecial+1 >= d.Pity var candidates []gamedata.EventGameReward total := uint64(0) @@ -270,7 +270,7 @@ func completeLine(g *GameState, n, typ, index uint64) bool { if n == 0 { return false } - for i := uint64(0); i < n; i++ { + for i := range n { var pos uint64 switch typ { case 1: diff --git a/go/internal/server/eventplay/battle.go b/go/internal/server/eventplay/battle.go index 454b627..be693f6 100644 --- a/go/internal/server/eventplay/battle.go +++ b/go/internal/server/eventplay/battle.go @@ -113,7 +113,7 @@ func (s *Service) CompleteBattle(req []byte, receipt string) ([]byte, error) { } rewards := []gamedata.Reward{} for _, r := range rs { - rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 } cost := num(row, 3) var costs []gamedata.Reward diff --git a/go/internal/server/eventplay/play.go b/go/internal/server/eventplay/play.go index 1ab2de4..c765621 100644 --- a/go/internal/server/eventplay/play.go +++ b/go/internal/server/eventplay/play.go @@ -12,7 +12,7 @@ import ( "encoding/json" "fmt" "math" - "sort" + "slices" "strings" "sync" "time" @@ -282,7 +282,7 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ func (s *Service) grant(identity string, rewards []gamedata.BattleReward) ([]byte, error) { rs := make([]gamedata.Reward, 0, len(rewards)) for _, r := range rewards { - rs = append(rs, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + rs = append(rs, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 } return s.economy.Apply(identity, nil, rs) } @@ -440,7 +440,7 @@ func (s *Service) game(path string, req []byte, session string, next *snapshot, for id := range d.Objects { ids = append(ids, id) } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) for _, id := range ids { startReply = wire.AppendVarint(startReply, 1, id) } @@ -630,7 +630,7 @@ func (s *Service) game(path string, req []byte, session string, next *snapshot, return nil, e } field := 1 - if f == "Run" { + if f == "Run" { //nolint:staticcheck // QF1003 field = 3 } else if f == "Field" { field = 5 diff --git a/go/internal/server/eventplay/play_test.go b/go/internal/server/eventplay/play_test.go index 499e9e6..7c6727d 100644 --- a/go/internal/server/eventplay/play_test.go +++ b/go/internal/server/eventplay/play_test.go @@ -11,8 +11,6 @@ import ( "time" ) -func nums(b []byte, n int) ([]uint64, error) { return list(b, n) } - type econ struct{ cost, granted uint64 } func (e *econ) Apply(_ string, c, r []gamedata.Reward) ([]byte, error) { @@ -188,7 +186,9 @@ func TestEventBattleChallengeUnknownAndDuplicateRejectedBeforeCost(t *testing.T) s.design.Tables["PackEventBattleTable"] = [][]byte{row} s.AttachBattleChallenges(gamedata.EventBattleChallenges{88: {{Type: 8, Reward: gamedata.BattleReward{Type: 3, Count: 25}}}}) start := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 5, 17), 8, 1), 9, 1), 4, 88) - s.EnterBattle(start, "sid:x") + if _, err := s.EnterBattle(start, "sid:x"); err != nil { + t.Fatal(err) + } bad := wire.AppendVarint(wire.AppendVarint(nil, 2, 1), 5, 1) if _, err := s.CompleteBattle(bad, "sid:x"); err == nil { t.Fatal("unknownchallenge") diff --git a/go/internal/server/eventplay/runtime.go b/go/internal/server/eventplay/runtime.go index 2ed3f11..3824895 100644 --- a/go/internal/server/eventplay/runtime.go +++ b/go/internal/server/eventplay/runtime.go @@ -5,6 +5,7 @@ import ( "bd2server/internal/server/wire" "fmt" "math" + "slices" "strings" ) @@ -52,7 +53,7 @@ func (s *Service) endScore(f string, req []byte, a Run) (uint64, error) { return 0, e } maxField := 8 - if a.Mode == 1 { + if a.Mode == 1 { //nolint:staticcheck // QF1003 maxField = 2 } else if a.Mode == 2 { maxField = 1 @@ -134,7 +135,7 @@ func (s *Service) progress(path string, req []byte, rk string, next *snapshot, k return nil, fmt.Errorf("eventplay: object invalid or already scored") } a.Objects = append(a.Objects, id) - if v.Type == 1 { + if v.Type == 1 { //nolint:staticcheck // QF1003 a.Score += v.Point } else if v.Type == 2 { a.HP += v.Point @@ -227,11 +228,6 @@ func (s *Service) progress(path string, req []byte, rk string, next *snapshot, k return out, nil } func has(a []uint64, id uint64) bool { - for _, x := range a { - if x == id { - return true - } - } - return false + return slices.Contains(a, id) } func seqSeed(n uint64) uint64 { return n & 2147483647 } diff --git a/go/internal/server/events/additional_rewards_test.go b/go/internal/server/events/additional_rewards_test.go index 3a10886..a006275 100644 --- a/go/internal/server/events/additional_rewards_test.go +++ b/go/internal/server/events/additional_rewards_test.go @@ -15,7 +15,11 @@ func TestAdditionalRewardCurrenciesAndRankChangeItemPersistAndReplay(t *testing. if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() eco, items, _ := economyFixture(t, repo, &economyGraph{}) eco.AttachOwnedItemDesign(map[uint64]map[uint64]bool{69: {1: true}}) rewards := []gamedata.Reward{{Type: 38, Count: 1}, {Type: 39, Count: 20}, {Type: 40, Count: 30}, {Type: 54, Count: 40}, {Type: 70, Count: 50}, {Type: 69, ID: 1, Count: 2}} @@ -78,12 +82,18 @@ func TestAdditionalRewardCurrenciesAndRankChangeItemPersistAndReplay(t *testing. t.Fatal(err) } _ = op.Rollback() - repo.Close() + if err := repo.Close(); err != nil { + t.Fatal(err) + } repo, err = accountstate.Open(path) if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() eco, _, _ = economyFixture(t, repo, &economyGraph{}) assertBalances(eco) } diff --git a/go/internal/server/events/ap_refresh.go b/go/internal/server/events/ap_refresh.go index 074fc32..ca479de 100644 --- a/go/internal/server/events/ap_refresh.go +++ b/go/internal/server/events/ap_refresh.go @@ -7,6 +7,7 @@ import ( "bd2server/internal/server/wire" "encoding/json" "fmt" + "maps" "math" "time" ) @@ -53,9 +54,7 @@ func (e *Economy) refreshAP(s *economySnapshot) error { if !reset { return nil } - for typ, max := range e.apCaps { - s.Balances[typ] = max - } + maps.Copy(s.Balances, e.apCaps) clock.Next = next raw, err := json.Marshal(clock) if err != nil { diff --git a/go/internal/server/events/avatar_rewards_test.go b/go/internal/server/events/avatar_rewards_test.go index 92e71a9..b1a14cd 100644 --- a/go/internal/server/events/avatar_rewards_test.go +++ b/go/internal/server/events/avatar_rewards_test.go @@ -29,12 +29,18 @@ func TestAvatarSetBatchPersistsMembersAndRollsBackWithAccount(t *testing.T) { t.Fatal(err) } _ = op.Rollback() - repo.Close() + if err := repo.Close(); err != nil { + t.Fatal(err) + } repo, err = accountstate.Open(path) if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() eco, items, wallet := economyFixture(t, repo, &economyGraph{}) eco.AttachAvatarRewards(avatarDesign()) if len(items.All()) != 0 || wallet.Snapshot().Gold != 5 { diff --git a/go/internal/server/events/buff_rewards_test.go b/go/internal/server/events/buff_rewards_test.go index cd2b06f..228657c 100644 --- a/go/internal/server/events/buff_rewards_test.go +++ b/go/internal/server/events/buff_rewards_test.go @@ -68,12 +68,18 @@ func TestPermanentBuffAndCurrencyAccountRollback(t *testing.T) { t.Fatal(err) } _ = op.Rollback() - repo.Close() + if err := repo.Close(); err != nil { + t.Fatal(err) + } repo, err = accountstate.Open(path) if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() eco, _, wallet := economyFixture(t, repo, &economyGraph{}) buffs, err = OpenBuffRewards(repo, design) if err != nil { diff --git a/go/internal/server/events/economy.go b/go/internal/server/events/economy.go index 574ada6..7f64a24 100644 --- a/go/internal/server/events/economy.go +++ b/go/internal/server/events/economy.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/json" "fmt" + "maps" "math" "sort" "strconv" @@ -99,9 +100,7 @@ func (e *Economy) AdditionalCurrencies() (map[int]uint64, error) { } func (e *Economy) load() (economySnapshot, error) { s := economySnapshot{Balances: map[uint64]uint64{}, Receipts: map[string]economyReceipt{}} - for t, n := range e.initial { - s.Balances[t] = n - } + maps.Copy(s.Balances, e.initial) b, err := e.store.Load("event_economy") if err != nil || b == nil { return s, err diff --git a/go/internal/server/events/economy_test.go b/go/internal/server/events/economy_test.go index 2695c3f..bfa4ffd 100644 --- a/go/internal/server/events/economy_test.go +++ b/go/internal/server/events/economy_test.go @@ -118,12 +118,18 @@ func TestEconomySQLiteAtomicRollbackAndRetry(t *testing.T) { t.Fatal("operation not applied") } _ = op.Rollback() - repo.Close() + if err := repo.Close(); err != nil { + t.Fatal(err) + } repo, e = accountstate.Open(path) if e != nil { t.Fatal(e) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() eco, items, wallet := economyFixture(t, repo, &economyGraph{}) if wallet.Snapshot().Gold != 5 || len(items.All()) != 0 { t.Fatal("rollback kept rewards/cost") diff --git a/go/internal/server/events/installed_reward_coverage_test.go b/go/internal/server/events/installed_reward_coverage_test.go index fa53379..0141b68 100644 --- a/go/internal/server/events/installed_reward_coverage_test.go +++ b/go/internal/server/events/installed_reward_coverage_test.go @@ -222,7 +222,9 @@ func TestInstalledScheduledTaskAttendanceAndPassRewardCoverage(t *testing.T) { if err = rows.Err(); err != nil { t.Fatal(err) } - rows.Close() + if err := rows.Close(); err != nil { + t.Fatal(err) + } } leaves := map[[2]uint64]gamedata.BattleReward{} var walk func(gamedata.BattleReward, map[uint64]bool) diff --git a/go/internal/server/events/native_costs.go b/go/internal/server/events/native_costs.go index 9e0270f..0e6135c 100644 --- a/go/internal/server/events/native_costs.go +++ b/go/internal/server/events/native_costs.go @@ -20,10 +20,7 @@ func (e *Economy) NativePurchaseCosts(cost gamedata.Reward) ([]gamedata.Reward, return nil, fmt.Errorf("events: diamond cost has an item ID") } balance := e.wallet.Snapshot() - free := cost.Count - if free > balance.FreeJewelry { - free = balance.FreeJewelry - } + free := min(cost.Count, balance.FreeJewelry) paid := cost.Count - free if paid > balance.Jewelry { return nil, fmt.Errorf("events: insufficient total diamonds") diff --git a/go/internal/server/eventtasks/observer_performance_test.go b/go/internal/server/eventtasks/observer_performance_test.go index 3017e3c..d64896e 100644 --- a/go/internal/server/eventtasks/observer_performance_test.go +++ b/go/internal/server/eventtasks/observer_performance_test.go @@ -49,7 +49,9 @@ func TestObserverUsesInventoryProjectionAndRestoresMemoryOnSaveFailure(t *testin } s.store = store p.count = 1 - s.BeforeDispatch("/grant", req(1)) + if err := s.BeforeDispatch("/grant", req(1)); err != nil { + t.Fatal(err) + } p.count = 2 b, e := s.AfterDispatch("/grant", req(1), nil) if e != nil || len(b) == 0 { @@ -103,7 +105,9 @@ func TestReadOnlyBatchHasNoObserverWritesButRealDeltaNotifiesOnce(t *testing.T) // Even if an upstream replay temporarily exposes the same before/after // delta, the committed request receipt must not increment tasks twice. p.count = 1 - s.BeforeDispatch("/grant", req(58)) + if err := s.BeforeDispatch("/grant", req(58)); err != nil { + t.Fatal(err) + } p.count = 2 b, e = s.AfterDispatch("/grant", req(58), nil) if e != nil || len(b) != 0 || counter.writes != 1 { diff --git a/go/internal/server/eventtasks/service.go b/go/internal/server/eventtasks/service.go index b0c8451..efb3a94 100644 --- a/go/internal/server/eventtasks/service.go +++ b/go/internal/server/eventtasks/service.go @@ -15,6 +15,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "sort" "strconv" "sync" @@ -173,7 +174,7 @@ func key(v ...uint64) string { var k string for i, n := range v { if i > 0 { - k += "/" + k += "/" //nolint:modernize // stringsbuilder } k += strconv.FormatUint(n, 10) } @@ -498,7 +499,7 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) } } } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) for _, id := range ids { rewards := s.attendanceRewards(v, a, group, id) if len(rewards) == 0 { @@ -549,7 +550,7 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) var u uint64 _, _ = fmt.Sscanf(uid, "%d", &u) for g, ids := range groups { - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) x := wire.AppendVarint(nil, 1, u) x = wire.AppendVarint(x, 2, g) for _, id := range ids { diff --git a/go/internal/server/feature/feature.go b/go/internal/server/feature/feature.go index 7098e9d..0143341 100644 --- a/go/internal/server/feature/feature.go +++ b/go/internal/server/feature/feature.go @@ -8,6 +8,7 @@ package feature import ( "errors" "fmt" + "maps" "bd2server/internal/server/wire" ) @@ -84,8 +85,6 @@ func (Service) Handle(path string, request []byte) (int, []byte, bool, error) { // EmptyPacketCodes returns a detached copy for diagnostics and protocol tests. func EmptyPacketCodes() map[string]int { result := make(map[string]int, len(emptyResponses)) - for path, code := range emptyResponses { - result[path] = code - } + maps.Copy(result, emptyResponses) return result } diff --git a/go/internal/server/feature/recipes.go b/go/internal/server/feature/recipes.go index 9ad4415..ebc2a93 100644 --- a/go/internal/server/feature/recipes.go +++ b/go/internal/server/feature/recipes.go @@ -4,7 +4,7 @@ import ( "encoding/binary" "fmt" "math" - "sort" + "slices" "bd2server/internal/server/gamedata" "bd2server/internal/server/player" @@ -23,10 +23,8 @@ func (s *RecipeService) Knows(id uint64) bool { if !s.design.IDs[id] { return false } - for _, known := range s.initial { - if known == id { - return true - } + if slices.Contains(s.initial, id) { + return true } for _, item := range s.items.All() { if item.Type == 7 && item.ID == id && item.Count > 0 { @@ -75,7 +73,7 @@ func (s *RecipeService) Handle(path string, request []byte) (int, []byte, bool, for id := range known { ids = append(ids, id) } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) var packed []byte for _, id := range ids { packed = binary.AppendUvarint(packed, id) diff --git a/go/internal/server/gacha/first_gacha_test.go b/go/internal/server/gacha/first_gacha_test.go index 95d3a0e..54ba214 100644 --- a/go/internal/server/gacha/first_gacha_test.go +++ b/go/internal/server/gacha/first_gacha_test.go @@ -14,7 +14,7 @@ func TestFirstGachaPreviewConfirmAndReplay(t *testing.T) { characters := make(map[uint64]gamedata.CharacterDesign) equipmentDesign := make(map[uint64]gamedata.EquipmentDesign) program := &gamedata.FirstGachaRewardGroup{ID: 20, DropCount: 1, DropType: 1} - for i := uint64(0); i < 5; i++ { + for i := range uint64(5) { costumeID := 61001 + i equipmentID := 71001 + i characters[costumeID] = fixtureCharacter(6100+i, 100+i) diff --git a/go/internal/server/gacha/gacha.go b/go/internal/server/gacha/gacha.go index dbc9713..48441b3 100644 --- a/go/internal/server/gacha/gacha.go +++ b/go/internal/server/gacha/gacha.go @@ -7,6 +7,7 @@ import ( "encoding/binary" "errors" "fmt" + "slices" "sync" "time" @@ -487,13 +488,7 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) ( return 146, nil, true, errors.New("gacha: discounted or content-ticket draw cannot use ordinary gacha tickets") } for _, ticket := range tickets { - allowed := false - for _, ticketID := range design.TicketIDs { - if ticket.ID == ticketID { - allowed = true - break - } - } + allowed := slices.Contains(design.TicketIDs, ticket.ID) if !allowed { return 146, nil, true, fmt.Errorf("gacha: ticket %d is not valid for gacha %d", ticket.ID, id) } @@ -706,10 +701,6 @@ func (s *Service) stepForGacha(gachaID uint64) (uint64, gamedata.GachaStepDesign // Scheduled equipment draws update GachaUser/GachaFixed accounting. Standalone // ticket-only draws have no schedule group and persist only their idempotency // marker plus the generated equipment instances. -func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, design gamedata.EquipmentGacha) (int, []byte, bool, error) { - return s.buyEquipmentWithIdentity(seq, buyType, tickets, design, "") -} - func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player.Item, design gamedata.EquipmentGacha, identity string) (int, []byte, bool, error) { if s.equipmentInventory == nil { return 146, nil, true, errors.New("gacha: equipment inventory not attached") @@ -722,13 +713,7 @@ func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player return 146, nil, true, errors.New("gacha: ticket-only equipment draw requires a ticket") } for _, ticket := range tickets { - allowed := false - for _, ticketID := range design.TicketIDs { - if ticket.ID == ticketID { - allowed = true - break - } - } + allowed := slices.Contains(design.TicketIDs, ticket.ID) if !allowed { return 146, nil, true, fmt.Errorf("gacha: ticket %d is not valid for equipment gacha %d", ticket.ID, design.ID) } diff --git a/go/internal/server/gacha/gacha_test.go b/go/internal/server/gacha/gacha_test.go index 0bdde87..8938883 100644 --- a/go/internal/server/gacha/gacha_test.go +++ b/go/internal/server/gacha/gacha_test.go @@ -783,7 +783,11 @@ func TestGachaMultiBuyFailureRollsBackWholeSQLiteOperation(t *testing.T) { if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() service := newMultiBuyTestService(t, repo) if err := service.collection.EnsurePersisted(); err != nil { t.Fatal(err) @@ -808,7 +812,11 @@ func TestGachaMultiBuyFailureRollsBackWholeSQLiteOperation(t *testing.T) { if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() reopened := newMultiBuyTestService(t, repo) if len(reopened.collection.Costumes()) != 0 || reopened.collection.GachaDailyCount("2026-09-30", 1, 0) != 0 || reopened.collection.GachaUser(1).Point != 0 { t.Fatal("failed request persisted a partial batch") @@ -1250,7 +1258,7 @@ func TestGachaPointManualExchangePersistsAndRetriesWithoutDoubleGrant(t *testing exchange := wire.AppendVarint(nil, 1, 42) exchange = wire.AppendVarint(exchange, 2, 205) exchange = wire.AppendVarint(exchange, 3, 5) - for i := 0; i < 2; i++ { + for i := range 2 { code, response, ok, err := service.Handle("/GachaPointManualExchange", exchange) if err != nil || !ok || code != 188 { t.Fatalf("exchange %d code=%d ok=%v err=%v", i, code, ok, err) @@ -1927,7 +1935,7 @@ func TestInstalledNewbieSelectionGuaranteeStopsAfterThirtyWithoutPoints(t *testi if _, _, _, err := service.Handle("/GachaSelectionSave", selection); err != nil { t.Fatal(err) } - for draw := uint64(0); draw < 3; draw++ { + for draw := range uint64(3) { request := wire.AppendVarint(nil, 1, draw+2) request = wire.AppendVarint(request, 2, 31) request = wire.AppendVarint(request, 3, 1) diff --git a/go/internal/server/gacha/login_purchase_count_test.go b/go/internal/server/gacha/login_purchase_count_test.go index 79b0fde..e1acc6a 100644 --- a/go/internal/server/gacha/login_purchase_count_test.go +++ b/go/internal/server/gacha/login_purchase_count_test.go @@ -81,7 +81,11 @@ func TestLoginPurchaseCountsRestoredFromSQLiteGrant(t *testing.T) { if err != nil { t.Fatal(err) } - defer repository.Close() + defer func() { + if err := repository.Close(); err != nil { + t.Error(err) + } + }() collection, err = player.OpenCollectionStore(repository, nil) if err != nil { t.Fatal(err) diff --git a/go/internal/server/gacha/point_exchange_test.go b/go/internal/server/gacha/point_exchange_test.go index c3ae1e0..f836a82 100644 --- a/go/internal/server/gacha/point_exchange_test.go +++ b/go/internal/server/gacha/point_exchange_test.go @@ -61,7 +61,7 @@ func TestGachaPointExchangeGrantsUpgradesOverflowsAndRetries(t *testing.T) { service.BeginSession("point-costume-login") var lastRequest, lastResponse []byte - for exchange := uint64(0); exchange < 7; exchange++ { + for exchange := range uint64(7) { request := wire.AppendVarint(nil, 1, 100+exchange) request = wire.AppendVarint(request, 2, groupID) code, response, handled, err := service.Handle("/GachaPointExchange", request) diff --git a/go/internal/server/gameconfig/config.go b/go/internal/server/gameconfig/config.go index b24d5ad..06290bd 100644 --- a/go/internal/server/gameconfig/config.go +++ b/go/internal/server/gameconfig/config.go @@ -9,6 +9,7 @@ import ( "io" "os" "path/filepath" + "slices" "bd2server/internal/server/configfile" ) @@ -97,11 +98,7 @@ func containsNull(value any) bool { } } case []any: - for _, child := range value { - if containsNull(child) { - return true - } - } + return slices.ContainsFunc(value, containsNull) } return false } diff --git a/go/internal/server/gamedata/achievement.go b/go/internal/server/gamedata/achievement.go index 28a61e7..91305b7 100644 --- a/go/internal/server/gamedata/achievement.go +++ b/go/internal/server/gamedata/achievement.go @@ -29,7 +29,7 @@ func loadAchievementCounterDesign(db *sql.DB) (*AchievementCounterDesign, error) if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() sets := map[int]map[int]bool{} conditions := map[int]AchievementCondition{} for rows.Next() { diff --git a/go/internal/server/gamedata/achievement_gameplay.go b/go/internal/server/gamedata/achievement_gameplay.go index fe7076d..3fd62d3 100644 --- a/go/internal/server/gamedata/achievement_gameplay.go +++ b/go/internal/server/gamedata/achievement_gameplay.go @@ -26,18 +26,18 @@ func LoadGameplayAchievementGrades(root, version string) (GameplayAchievementGra var id uint64 var raw []byte if err := rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return d, err } grade, err := packedInts(raw, spec.field) if err != nil || len(grade) != 1 || grade[0] == 0 { - rows.Close() + _ = rows.Close() return d, fmt.Errorf("gamedata: invalid achievement grade %s/%d", spec.table, id) } spec.target[id] = grade[0] } err = rows.Err() - rows.Close() + _ = rows.Close() if err != nil { return d, err } diff --git a/go/internal/server/gamedata/achievement_level.go b/go/internal/server/gamedata/achievement_level.go index 6c062a3..bb0a7fe 100644 --- a/go/internal/server/gamedata/achievement_level.go +++ b/go/internal/server/gamedata/achievement_level.go @@ -22,7 +22,7 @@ func LoadAchievementLevelDesign(root, version string) (*AchievementLevelDesign, if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() d := &AchievementLevelDesign{} for rows.Next() { var raw []byte diff --git a/go/internal/server/gamedata/achievement_test.go b/go/internal/server/gamedata/achievement_test.go index cd53696..665809a 100644 --- a/go/internal/server/gamedata/achievement_test.go +++ b/go/internal/server/gamedata/achievement_test.go @@ -13,7 +13,11 @@ func TestAchievementCounterDesignUsesRootGroupsAndBothContents(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() db.SetMaxOpenConns(1) if _, err = db.Exec("CREATE TABLE AchievementTable (ProtoBuf BLOB)"); err != nil { t.Fatal(err) @@ -40,13 +44,21 @@ func TestAchievementRewardDesignIncludesOrdinaryZeroContentsAndTargets(t *testin if err != nil { t.Fatal(err) } - defer db.Close() - db.Exec("CREATE TABLE AchievementTable(ProtoBuf BLOB)") + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() + if _, err := db.Exec("CREATE TABLE AchievementTable(ProtoBuf BLOB)"); err != nil { + t.Fatal(err) + } raw := wire.AppendVarint(wire.AppendVarint(nil, 9, 987), 11, 1) raw = wire.AppendVarint(raw, 8, 9) raw = append(raw, 25) raw = binary.LittleEndian.AppendUint64(raw, math.Float64bits(10)) - db.Exec("INSERT INTO AchievementTable VALUES (?)", raw) + if _, err := db.Exec("INSERT INTO AchievementTable VALUES (?)", raw); err != nil { + t.Fatal(err) + } d := &MissionDesign{Achievements: map[AchievementKey]AchievementDesign{}} if err := loadAchievementRows(db, d); err != nil { t.Fatal(err) diff --git a/go/internal/server/gamedata/avatar_rewards_test.go b/go/internal/server/gamedata/avatar_rewards_test.go index 4905041..406f871 100644 --- a/go/internal/server/gamedata/avatar_rewards_test.go +++ b/go/internal/server/gamedata/avatar_rewards_test.go @@ -12,7 +12,11 @@ func TestAvatarRewardsLoadAndValidateTypedMembers(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() for _, table := range []string{"AvatarItemTable", "AvatarMotionTable", "AvatarCharTable", "AvatarSetTable"} { if _, err = db.Exec("CREATE TABLE " + table + "(ProtoBuf BLOB)"); err != nil { t.Fatal(err) diff --git a/go/internal/server/gamedata/battle_difficulty_test.go b/go/internal/server/gamedata/battle_difficulty_test.go index 0625ab0..e3df71b 100644 --- a/go/internal/server/gamedata/battle_difficulty_test.go +++ b/go/internal/server/gamedata/battle_difficulty_test.go @@ -12,7 +12,11 @@ func TestBattleDifficultyUsesExistingDesignRows(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err := db.Exec("CREATE TABLE BattleDeckTable(id INTEGER PRIMARY KEY, ProtoBuf BLOB)"); err != nil { t.Fatal(err) } diff --git a/go/internal/server/gamedata/battle_phase.go b/go/internal/server/gamedata/battle_phase.go index ac4ec2c..45bc15a 100644 --- a/go/internal/server/gamedata/battle_phase.go +++ b/go/internal/server/gamedata/battle_phase.go @@ -44,7 +44,7 @@ func battleDeckPhasesFromDB(db *sql.DB, monsterID, deckID uint64) ([]BattlePhase if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() var phases []BattlePhase selected := false for rows.Next() { diff --git a/go/internal/server/gamedata/battle_phase_test.go b/go/internal/server/gamedata/battle_phase_test.go index 6882142..872e2fc 100644 --- a/go/internal/server/gamedata/battle_phase_test.go +++ b/go/internal/server/gamedata/battle_phase_test.go @@ -12,7 +12,11 @@ func TestBattleDeckPhasesDesignRows(t *testing.T) { if e != nil { t.Fatal(e) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() db.SetMaxOpenConns(1) for _, q := range []string{"CREATE TABLE FieldMonsterTable(id INTEGER PRIMARY KEY, ProtoBuf BLOB)", "CREATE TABLE PhaseBattleTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)"} { if _, e := db.Exec(q); e != nil { @@ -64,13 +68,19 @@ func TestBattleDeckPhasesRetainSelectedDifficulty(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() for _, q := range []string{"CREATE TABLE FieldMonsterTable(id INTEGER PRIMARY KEY,ProtoBuf BLOB)", "CREATE TABLE PhaseBattleTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)", "CREATE TABLE BattleDeckTable(id INTEGER PRIMARY KEY,ProtoBuf BLOB)"} { if _, err := db.Exec(q); err != nil { t.Fatal(err) } } - db.Exec("INSERT INTO FieldMonsterTable VALUES(8,?)", wire.AppendVarint(nil, 21, 1)) + if _, err := db.Exec("INSERT INTO FieldMonsterTable VALUES(8,?)", wire.AppendVarint(nil, 21, 1)); err != nil { + t.Fatal(err) + } for _, v := range []struct{ id, deck uint64 }{{1, 8}, {2, 9}} { data := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 2, v.deck), 3, 1), 4, v.id) if _, err := db.Exec("INSERT INTO PhaseBattleTable VALUES(1,?,?)", v.id, data); err != nil { diff --git a/go/internal/server/gamedata/buff_reward_design.go b/go/internal/server/gamedata/buff_reward_design.go index c24c855..d8732b9 100644 --- a/go/internal/server/gamedata/buff_reward_design.go +++ b/go/internal/server/gamedata/buff_reward_design.go @@ -17,7 +17,7 @@ func LoadBuffRewardDesign(root, version string) (map[uint64]PictorialBuffStat, e if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() out := map[uint64]PictorialBuffStat{} for rows.Next() { var raw []byte diff --git a/go/internal/server/gamedata/calendar_ids.go b/go/internal/server/gamedata/calendar_ids.go index 9a6972d..bc276b7 100644 --- a/go/internal/server/gamedata/calendar_ids.go +++ b/go/internal/server/gamedata/calendar_ids.go @@ -23,7 +23,7 @@ func loadCalendarIDs(root, version, table string) (map[uint64]bool, error) { if e != nil { return nil, e } - defer rows.Close() + defer func() { _ = rows.Close() }() ids := map[uint64]bool{} for rows.Next() { var id uint64 diff --git a/go/internal/server/gamedata/cash_entitlements_test.go b/go/internal/server/gamedata/cash_entitlements_test.go index 069e773..649429c 100644 --- a/go/internal/server/gamedata/cash_entitlements_test.go +++ b/go/internal/server/gamedata/cash_entitlements_test.go @@ -11,25 +11,37 @@ func TestCashEntitlementDesignUsesTicketTypesAndAttendanceMapping(t *testing.T) if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() for _, table := range []string{"AvatarSetTable", "ContentTicketTable", "ContentOpenTable", "AttendanceRewardTable"} { if _, err = db.Exec("CREATE TABLE " + table + "(ProtoBuf BLOB)"); err != nil { t.Fatal(err) } } - db.Exec("INSERT INTO AvatarSetTable VALUES(?)", wire.AppendVarint(nil, 6, 10)) + if _, err := db.Exec("INSERT INTO AvatarSetTable VALUES(?)", wire.AppendVarint(nil, 6, 10)); err != nil { + t.Fatal(err) + } raw := wire.AppendVarint(nil, 4, 38) raw = wire.AppendVarint(raw, 8, 2) - db.Exec("INSERT INTO ContentTicketTable VALUES(?)", raw) + if _, err := db.Exec("INSERT INTO ContentTicketTable VALUES(?)", raw); err != nil { + t.Fatal(err) + } raw = wire.AppendVarint(nil, 6, 38) raw = wire.AppendVarint(raw, 2, 1) - db.Exec("INSERT INTO ContentOpenTable VALUES(?)", raw) + if _, err := db.Exec("INSERT INTO ContentOpenTable VALUES(?)", raw); err != nil { + t.Fatal(err) + } for _, id := range []uint64{2, 1} { raw = wire.AppendVarint(nil, 1, 38) raw = wire.AppendVarint(raw, 2, id) raw = wire.AppendVarint(raw, 3, 60) raw = wire.AppendVarint(raw, 5, 3) - db.Exec("INSERT INTO AttendanceRewardTable VALUES(?)", raw) + if _, err := db.Exec("INSERT INTO AttendanceRewardTable VALUES(?)", raw); err != nil { + t.Fatal(err) + } } d, err := loadCashEntitlementDesign(db) if err != nil || !d.AvatarSets[10] || d.TicketTypes[38] != 2 || d.AttendanceTypes[38] != 1 || len(d.Attendance[38]) != 2 || d.Attendance[38][0].ID != 1 { diff --git a/go/internal/server/gamedata/cash_products.go b/go/internal/server/gamedata/cash_products.go index 459f691..06dea77 100644 --- a/go/internal/server/gamedata/cash_products.go +++ b/go/internal/server/gamedata/cash_products.go @@ -48,18 +48,18 @@ func loadCashCatalog(db *sql.DB) (*CashCatalog, error) { for rows.Next() { var raw []byte if err = rows.Scan(&raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } p, e := decodeCashProduct(raw) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } c.Products = append(c.Products, p) } err = rows.Err() - rows.Close() + _ = rows.Close() if err != nil { return nil, err } @@ -136,7 +136,7 @@ func readCashMetadata(db *sql.DB, table string, visit func([]byte) error) error if err != nil { return err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var raw []byte if err = rows.Scan(&raw); err != nil { diff --git a/go/internal/server/gamedata/cash_products_test.go b/go/internal/server/gamedata/cash_products_test.go index 654ce80..12610ef 100644 --- a/go/internal/server/gamedata/cash_products_test.go +++ b/go/internal/server/gamedata/cash_products_test.go @@ -11,7 +11,11 @@ func TestCashCatalogPreservesKeysAliasesAndLimits(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() db.SetMaxOpenConns(1) for _, q := range []string{"CREATE TABLE CashProductTable(groupId INTEGER,id INTEGER,saleGroup INTEGER,ProtoBuf BLOB)", "CREATE TABLE RandomBoxTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE RewardGroupTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE CashShopTable(ProtoBuf BLOB)", "CREATE TABLE CashPackageTable(ProtoBuf BLOB)", "CREATE TABLE EventShopTable(ProtoBuf BLOB)"} { if _, err = db.Exec(q); err != nil { diff --git a/go/internal/server/gamedata/char_awake.go b/go/internal/server/gamedata/char_awake.go index f28632a..6b6952d 100644 --- a/go/internal/server/gamedata/char_awake.go +++ b/go/internal/server/gamedata/char_awake.go @@ -65,7 +65,7 @@ func loadCharAwakeDesign(db *sql.DB) (*CharAwakeDesign, error) { var id uint64 var proto []byte if err := rows.Scan(&id, &proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } counts, _ := packedInts(proto, 1) @@ -75,27 +75,31 @@ func loadCharAwakeDesign(db *sql.DB) (*CharAwakeDesign, error) { statType, _ := packedInts(proto, 5) statValue, hasStatValue, err := fixed64Double(proto, 6) if err != nil || len(protoID) != 1 || protoID[0] != id || len(statType) != 1 || !hasStatValue || math.IsNaN(statValue) || math.IsInf(statValue, 0) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid CharAwakeGrowthTable row %d", id) } if len(counts) != len(ids) || len(counts) != len(types) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: mismatched CharAwakeGrowthTable costs %d", id) } entry := CharAwakeGrowth{ID: id, StatType: statType[0], StatValue: statValue} if entry.StatType == 0 || entry.StatType > 20 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: unsupported awakening stat type %d in row %d", entry.StatType, id) } for i := range counts { if counts[i] == 0 || (types[i] != 4 && types[i] != 8) || (types[i] == 4 && ids[i] != 0) || (types[i] == 8 && ids[i] == 0) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid awakening cost in row %d", id) } entry.Costs = append(entry.Costs, CharAwakeCost{Type: types[i], ID: ids[i], Count: counts[i]}) } growth[id] = entry } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } if err := rows.Close(); err != nil { return nil, err } @@ -109,24 +113,28 @@ func loadCharAwakeDesign(db *sql.DB) (*CharAwakeDesign, error) { var id uint64 var proto []byte if err := rows.Scan(&id, &proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } growthIDs, _ := packedInts(proto, 1) protoID, _ := packedInts(proto, 2) if len(protoID) != 1 || protoID[0] != id || len(growthIDs) == 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid CharImprintTable row %d", id) } for _, growthID := range growthIDs { entry, ok := growth[growthID] if !ok || len(entry.Costs) == 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: imprint %d has invalid growth %d", id, growthID) } imprints[id] = append(imprints[id], entry) } } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } if err := rows.Close(); err != nil { return nil, err } @@ -140,7 +148,7 @@ func loadCharAwakeDesign(db *sql.DB) (*CharAwakeDesign, error) { var rowID uint64 var proto []byte if err := rows.Scan(&rowID, &proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } active, _ := packedInts(proto, 1) @@ -150,14 +158,14 @@ func loadCharAwakeDesign(db *sql.DB) (*CharAwakeDesign, error) { slot2, _ := packedInts(proto, 5) slot3, _ := packedInts(proto, 6) if len(active) != 1 || active[0] != 1 || len(unique) != 1 || unique[0] != rowID || len(slot1) != 1 || len(slot2) != 1 || len(slot3) != 1 || len(awakeIDs) == 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid CharAwakeTable row %d", rowID) } entry := CharAwakeCharacter{UniqueCharID: rowID, Active: true, ImprintIDs: [3]uint64{slot1[0], slot2[0], slot3[0]}} for i, imprintID := range entry.ImprintIDs { levels, ok := imprints[imprintID] if !ok { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: awakening %d has unknown imprint %d", rowID, imprintID) } entry.ImprintGrowth[i] = append([]CharAwakeGrowth(nil), levels...) @@ -165,17 +173,21 @@ func loadCharAwakeDesign(db *sql.DB) (*CharAwakeDesign, error) { for i, growthID := range awakeIDs { awakeGrowth, ok := growth[growthID] if !ok || (i > 0 && len(awakeGrowth.Costs) != 0) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: awakening %d has invalid growth %d", rowID, growthID) } entry.AwakeGrowth = append(entry.AwakeGrowth, awakeGrowth) } if len(entry.AwakeGrowth[0].Costs) == 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: awakening %d has no activation cost", rowID) } design.Characters[rowID] = entry } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } if err := rows.Close(); err != nil { return nil, err } @@ -192,13 +204,13 @@ func loadCharAwakeDesign(db *sql.DB) (*CharAwakeDesign, error) { for rows.Next() { var row stageRow if err := rows.Scan(&row.id, &row.proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } sourceRows = append(sourceRows, row) } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if err := rows.Close(); err != nil { @@ -214,24 +226,18 @@ func loadCharAwakeDesign(db *sql.DB) (*CharAwakeDesign, error) { continue } if len(growthID) != 1 || len(grade) != 1 || len(growthGrade) != 1 { - rows.Close() return nil, fmt.Errorf("gamedata: invalid awakening character stage %d", id) } var growthProto []byte if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthID[0]).Scan(&growthProto); err != nil { - rows.Close() return nil, fmt.Errorf("gamedata: awakening character %d growth: %w", id, err) } maximum, _ := packedInts(growthProto, 9) if len(maximum) != 1 || maximum[0] == 0 { - rows.Close() return nil, fmt.Errorf("gamedata: invalid awakening character maximum %d", id) } design.Stages[id] = CharAwakeCharacterStage{UniqueCharID: unique[0], Grade: grade[0], GrowthGrade: growthGrade[0], MaximumLevel: maximum[0]} } - if err := rows.Close(); err != nil { - return nil, err - } if len(design.Characters) == 0 || len(design.Stages) == 0 { return nil, fmt.Errorf("gamedata: awakening design is empty") } diff --git a/go/internal/server/gamedata/clear_packages.go b/go/internal/server/gamedata/clear_packages.go index 4694516..0f3bafa 100644 --- a/go/internal/server/gamedata/clear_packages.go +++ b/go/internal/server/gamedata/clear_packages.go @@ -22,12 +22,12 @@ func LoadClearPackageCatalog(root, version string) (*ClearPackageCatalog, error) for rows.Next() { var raw []byte if err = rows.Scan(&raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } v, err := cashScalars(raw, 1, 2, 3, 4, 5, 6) if err != nil { - rows.Close() + _ = rows.Close() return nil, err } r := ClearPackageRewardDesign{Kind: uint64(kind), TicketID: v[0], GroupID: v[1], Type: v[5]} @@ -41,13 +41,13 @@ func LoadClearPackageCatalog(root, version string) (*ClearPackageCatalog, error) r.TargetID = v[4] } if r.GroupID == 0 || r.TicketID == 0 || r.TargetID == 0 || r.RandomBoxID == 0 || r.Type > 1 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid clear-package reward") } c.Rewards = append(c.Rewards, r) } err = rows.Err() - rows.Close() + _ = rows.Close() if err != nil { return nil, err } diff --git a/go/internal/server/gamedata/content_open.go b/go/internal/server/gamedata/content_open.go index 75c43d7..ce217af 100644 --- a/go/internal/server/gamedata/content_open.go +++ b/go/internal/server/gamedata/content_open.go @@ -54,7 +54,7 @@ func loadContentOpeningDesign(db *sql.DB) (*ContentOpeningDesign, error) { if err == nil { err = rows.Err() } - rows.Close() + _ = rows.Close() if err != nil { return nil, err } @@ -62,7 +62,7 @@ func loadContentOpeningDesign(db *sql.DB) (*ContentOpeningDesign, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() d := &ContentOpeningDesign{} seen := map[[2]uint64]bool{} for rows.Next() { diff --git a/go/internal/server/gamedata/content_open_test.go b/go/internal/server/gamedata/content_open_test.go index f580495..e2f8029 100644 --- a/go/internal/server/gamedata/content_open_test.go +++ b/go/internal/server/gamedata/content_open_test.go @@ -12,7 +12,11 @@ func contentOpeningTestDB(t *testing.T) *sql.DB { t.Fatal(err) } db.SetMaxOpenConns(1) - t.Cleanup(func() { db.Close() }) + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }) for _, q := range []string{"CREATE TABLE ContentOpenTable(groupId INTEGER,id INTEGER,ticketId INTEGER,ProtoBuf BLOB)", "CREATE TABLE ContentTicketTable(id INTEGER,ProtoBuf BLOB)"} { if _, err := db.Exec(q); err != nil { t.Fatal(err) diff --git a/go/internal/server/gamedata/content_ticket.go b/go/internal/server/gamedata/content_ticket.go index 67941b3..eca2c78 100644 --- a/go/internal/server/gamedata/content_ticket.go +++ b/go/internal/server/gamedata/content_ticket.go @@ -14,7 +14,7 @@ func LoadGachaContentTicketDesign(root, version string) (*GachaContentTicketDesi if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() d := &GachaContentTicketDesign{IDs: map[uint64]bool{}} for rows.Next() { var id uint64 diff --git a/go/internal/server/gamedata/cooking_recipes.go b/go/internal/server/gamedata/cooking_recipes.go index 0450ea3..6a4dc10 100644 --- a/go/internal/server/gamedata/cooking_recipes.go +++ b/go/internal/server/gamedata/cooking_recipes.go @@ -24,7 +24,7 @@ func loadCookingRecipeDesign(db *sql.DB) (*CookingRecipeDesign, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() design := &CookingRecipeDesign{IDs: map[uint64]bool{}} for rows.Next() { var id int64 diff --git a/go/internal/server/gamedata/cooking_recipes_test.go b/go/internal/server/gamedata/cooking_recipes_test.go index 7362f54..eda1b1d 100644 --- a/go/internal/server/gamedata/cooking_recipes_test.go +++ b/go/internal/server/gamedata/cooking_recipes_test.go @@ -10,7 +10,11 @@ func TestCookingRecipesReadCatalogIdentities(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err := db.Exec("CREATE TABLE CookingTable(id INTEGER); INSERT INTO CookingTable VALUES(503),(707)"); err != nil { t.Fatal(err) } diff --git a/go/internal/server/gamedata/costume_burst.go b/go/internal/server/gamedata/costume_burst.go index f4f4e1e..f276e6e 100644 --- a/go/internal/server/gamedata/costume_burst.go +++ b/go/internal/server/gamedata/costume_burst.go @@ -52,11 +52,11 @@ func loadCostumeBurstDesign(db *sql.DB) (*CostumeBurstDesign, error) { var groupID, level uint64 var raw []byte if err := rows.Scan(&groupID, &level, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if groupID == 0 || level == 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid costume burst identity %d/%d", groupID, level) } groups, groupErr := packedInts(raw, 8) @@ -65,28 +65,28 @@ func loadCostumeBurstDesign(db *sql.DB) (*CostumeBurstDesign, error) { itemIDs, itemIDErr := packedInts(raw, 11) types, typeErr := packedInts(raw, 12) if groupErr != nil || idErr != nil || countErr != nil || itemIDErr != nil || typeErr != nil { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: costume burst %d/%d has invalid fields", groupID, level) } if len(groups) != 1 || groups[0] != groupID || len(ids) != 1 || ids[0] != level { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: costume burst %d/%d has mismatched proto identity", groupID, level) } if len(counts) == 0 || len(counts) != len(itemIDs) || len(itemIDs) != len(types) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: costume burst %d/%d has mismatched costs", groupID, level) } entry := CostumeBurstLevel{CostumeID: groupID, Level: level} for i := range counts { if counts[i] == 0 || (types[i] == 4 && itemIDs[i] != 0) || (types[i] == 8 && itemIDs[i] == 0) || (types[i] != 4 && types[i] != 8) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: costume burst %d/%d has invalid cost", groupID, level) } entry.Costs = append(entry.Costs, PromotionCost{Type: types[i], ID: itemIDs[i], Count: counts[i]}) } if _, exists := design.Levels[groupID][level]; exists { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: duplicate costume burst %d/%d", groupID, level) } if design.Levels[groupID] == nil { @@ -95,7 +95,7 @@ func loadCostumeBurstDesign(db *sql.DB) (*CostumeBurstDesign, error) { design.Levels[groupID][level] = entry } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if err := rows.Close(); err != nil { diff --git a/go/internal/server/gamedata/costume_burst_test.go b/go/internal/server/gamedata/costume_burst_test.go index 170e75f..0ef656f 100644 --- a/go/internal/server/gamedata/costume_burst_test.go +++ b/go/internal/server/gamedata/costume_burst_test.go @@ -12,7 +12,11 @@ func TestCostumeBurstDesignAndUpgradeRule(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err := db.Exec(`CREATE TABLE CostumeBurstTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))`); err != nil { t.Fatal(err) } @@ -55,7 +59,11 @@ func TestCostumeBurstRejectsInvalidIdentityCostAndGap(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err := db.Exec(`CREATE TABLE CostumeBurstTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))`); err != nil { t.Fatal(err) } diff --git a/go/internal/server/gamedata/costume_potential.go b/go/internal/server/gamedata/costume_potential.go index 4d158af..e2c0a8a 100644 --- a/go/internal/server/gamedata/costume_potential.go +++ b/go/internal/server/gamedata/costume_potential.go @@ -48,21 +48,21 @@ func loadCostumePotentialDesign(db *sql.DB) (*CostumePotentialDesign, error) { var id uint64 var raw []byte if err = groups.Scan(&id, &raw); err != nil { - groups.Close() + _ = groups.Close() return nil, err } active, e := optionalScalar(raw, 3) if e != nil || active > 1 { - groups.Close() + _ = groups.Close() return nil, fmt.Errorf("gamedata: invalid potential active group") } d.CostumeActive[id] = active == 1 } if err = groups.Err(); err != nil { - groups.Close() + _ = groups.Close() return nil, err } - groups.Close() + _ = groups.Close() rows, err := db.Query("SELECT groupId,id,ProtoBuf FROM CostumeNodeTable ORDER BY groupId,id") if err != nil { return nil, err @@ -71,14 +71,14 @@ func loadCostumePotentialDesign(db *sql.DB) (*CostumePotentialDesign, error) { var groupID, id uint64 var proto []byte if err := rows.Scan(&groupID, &id, &proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } counts, _ := packedInts(proto, 1) ids, _ := packedInts(proto, 2) types, _ := packedInts(proto, 3) if len(counts) == 0 || len(counts) != len(ids) || len(counts) != len(types) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid costume potential costs %d/%d", groupID, id) } grade, _ := packedInts(proto, 17) @@ -88,7 +88,7 @@ func loadCostumePotentialDesign(db *sql.DB) (*CostumePotentialDesign, error) { statTypes, statTypeErr := packedInts(proto, 29) statValue, _, statValueErr := fixed64Double(proto, 30) if nodeTypeErr != nil || statTypeErr != nil || statValueErr != nil || len(nodeTypes) != 1 || len(statTypes) > 1 || math.IsNaN(statValue) || math.IsInf(statValue, 0) || statValue < 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid costume potential stats %d/%d", groupID, id) } node.NodeType = nodeTypes[0] @@ -97,18 +97,18 @@ func loadCostumePotentialDesign(db *sql.DB) (*CostumePotentialDesign, error) { node.StatType = statTypes[0] } if (node.NodeType == 1 || node.NodeType == 2) && (node.StatType == 0 || node.StatType > 20) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: unsupported costume potential stat %d/%d option %d", groupID, id, node.StatType) } if len(grade) == 1 { node.ConditionGrade = grade[0] } else if len(grade) > 1 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid costume potential grade %d/%d", groupID, id) } for i := range counts { if counts[i] == 0 || types[i] == 0 || (types[i] != 4 && ids[i] == 0) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid costume potential cost %d/%d", groupID, id) } node.Costs = append(node.Costs, CostumePotentialCost{Type: types[i], ID: ids[i], Count: counts[i]}) @@ -118,6 +118,10 @@ func loadCostumePotentialDesign(db *sql.DB) (*CostumePotentialDesign, error) { } d.Nodes[groupID][id] = node } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } if err := rows.Close(); err != nil { return nil, err } @@ -129,7 +133,7 @@ func loadCostumePotentialDesign(db *sql.DB) (*CostumePotentialDesign, error) { var id uint64 var proto []byte if err := rows.Scan(&id, &proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } unique, _ := packedInts(proto, 27) @@ -137,6 +141,10 @@ func loadCostumePotentialDesign(db *sql.DB) (*CostumePotentialDesign, error) { d.CostumeUnique[id] = unique[0] } } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } if err := rows.Close(); err != nil { return nil, err } @@ -148,13 +156,13 @@ func loadCostumePotentialDesign(db *sql.DB) (*CostumePotentialDesign, error) { var id uint64 var proto []byte if err := rows.Scan(&id, &proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } grade, _ := packedInts(proto, 10) typeID, e := optionalScalar(proto, 19) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } d.CharacterTypes[id] = typeID @@ -164,6 +172,10 @@ func loadCostumePotentialDesign(db *sql.DB) (*CostumePotentialDesign, error) { d.CharacterUnique[id] = unique[0] } } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } if err := rows.Close(); err != nil { return nil, err } diff --git a/go/internal/server/gamedata/database.go b/go/internal/server/gamedata/database.go index 21bdbe9..319c8fc 100644 --- a/go/internal/server/gamedata/database.go +++ b/go/internal/server/gamedata/database.go @@ -44,7 +44,7 @@ func readEntry(root, version, name, label string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("gamedata: open database archive: %w", err) } - defer reader.Close() + defer func() { _ = reader.Close() }() for _, entry := range reader.File { if !strings.EqualFold(entry.Name, name) { continue diff --git a/go/internal/server/gamedata/database_cache.go b/go/internal/server/gamedata/database_cache.go index e9285cd..81d9be9 100644 --- a/go/internal/server/gamedata/database_cache.go +++ b/go/internal/server/gamedata/database_cache.go @@ -93,7 +93,7 @@ func memoryDatabase(plain []byte) (*sql.DB, error) { db.SetMaxIdleConns(1) conn, err := db.Conn(context.Background()) if err != nil { - db.Close() + _ = db.Close() return nil, err } err = conn.Raw(func(driver any) error { @@ -118,7 +118,7 @@ func memoryDatabase(plain []byte) (*sql.DB, error) { err = closeErr } if err != nil { - db.Close() + _ = db.Close() return nil, err } return db, nil @@ -170,7 +170,6 @@ func (c *DatabaseCache) Open(root, version, logical string) (*sql.DB, func(), er db, loadErr = memoryDatabase(plain) } } - plain = nil c.loadMu.Unlock() if loadErr == nil { slog.Debug("GameData database cached", "logical", logical, "version", version, "image_bytes", size, "load_ms", float64(time.Since(loadStarted).Microseconds())/1000) diff --git a/go/internal/server/gamedata/database_cache_test.go b/go/internal/server/gamedata/database_cache_test.go index 5f03b69..65360e7 100644 --- a/go/internal/server/gamedata/database_cache_test.go +++ b/go/internal/server/gamedata/database_cache_test.go @@ -18,7 +18,11 @@ func cachePlain(t *testing.T, value int) []byte { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err = db.Exec(fmt.Sprintf("CREATE TABLE test(value INTEGER);INSERT INTO test VALUES(%d)", value)); err != nil { t.Fatal(err) } @@ -26,7 +30,11 @@ func cachePlain(t *testing.T, value int) []byte { if err != nil { t.Fatal(err) } - defer conn.Close() + defer func() { + if err := conn.Close(); err != nil { + t.Error(err) + } + }() var raw []byte err = conn.Raw(func(v any) error { var e error @@ -72,14 +80,16 @@ func TestDatabaseCacheSingleFlightReadOnlyAndLifetime(t *testing.T) { cacheArchive(t, root, 1) plain := cachePlain(t, 7) c := NewDatabaseCache(1 << 20) - defer c.Close() + defer func() { + if err := c.Close(); err != nil { + t.Error(err) + } + }() var loads atomic.Int32 c.loader = func(string, string, string) ([]byte, error) { loads.Add(1); return plain, nil } var wg sync.WaitGroup - for i := 0; i < 12; i++ { - wg.Add(1) - go func() { - defer wg.Done() + for range 12 { + wg.Go(func() { err := c.WithDatabase(root, "v1", "common", func(db *sql.DB) error { var n int if err := db.QueryRow("SELECT value FROM test").Scan(&n); err != nil { @@ -96,7 +106,7 @@ func TestDatabaseCacheSingleFlightReadOnlyAndLifetime(t *testing.T) { if err != nil { t.Error(err) } - }() + }) } wg.Wait() if loads.Load() != 1 { @@ -124,7 +134,11 @@ func TestDatabaseCacheFailureRetryReplacementAndEviction(t *testing.T) { cacheArchive(t, root, 1) plain := cachePlain(t, 1) c := NewDatabaseCache(int64(len(plain))) - defer c.Close() + defer func() { + if err := c.Close(); err != nil { + t.Error(err) + } + }() var loads int c.loader = func(string, string, string) ([]byte, error) { loads++ @@ -180,7 +194,11 @@ func TestDatabaseCacheMutationDuringLoadFailsAndDoesNotPoison(t *testing.T) { cacheArchive(t, root, 1) plain := cachePlain(t, 3) c := NewDatabaseCache(1 << 20) - defer c.Close() + defer func() { + if err := c.Close(); err != nil { + t.Error(err) + } + }() calls := 0 c.loader = func(string, string, string) ([]byte, error) { calls++ @@ -274,7 +292,11 @@ func TestDatabaseCacheRootAndVersionIsolation(t *testing.T) { } plainA, plainB, plainV2 := cachePlain(t, 1), cachePlain(t, 2), cachePlain(t, 3) c := NewDatabaseCache(1 << 20) - defer c.Close() + defer func() { + if err := c.Close(); err != nil { + t.Error(err) + } + }() loads := 0 c.loader = func(root, version, _ string) ([]byte, error) { loads++ diff --git a/go/internal/server/gamedata/database_test.go b/go/internal/server/gamedata/database_test.go index 738544f..0076d3f 100644 --- a/go/internal/server/gamedata/database_test.go +++ b/go/internal/server/gamedata/database_test.go @@ -76,16 +76,24 @@ func writeDatabaseTestArchive(t *testing.T, path, member string, content []byte) archive := zip.NewWriter(file) entry, err := archive.Create(member) if err != nil { - file.Close() + if err := file.Close(); err != nil { + t.Error(err) + } t.Fatal(err) } if _, err := entry.Write(content); err != nil { - archive.Close() - file.Close() + if err := archive.Close(); err != nil { + t.Error(err) + } + if err := file.Close(); err != nil { + t.Error(err) + } t.Fatal(err) } if err := archive.Close(); err != nil { - file.Close() + if err := file.Close(); err != nil { + t.Error(err) + } t.Fatal(err) } if err := file.Close(); err != nil { diff --git a/go/internal/server/gamedata/dispatch.go b/go/internal/server/gamedata/dispatch.go index 1a6a087..60ab6e8 100644 --- a/go/internal/server/gamedata/dispatch.go +++ b/go/internal/server/gamedata/dispatch.go @@ -99,7 +99,7 @@ func LoadDispatchDesign(root, version string, group, id uint64) (*DispatchDesign return nil, e } for _, r := range rs { - d.Rewards = append(d.Rewards, BattleReward{r.Type, r.ID, r.Count}) + d.Rewards = append(d.Rewards, BattleReward{r.Type, r.ID, r.Count}) //nolint:staticcheck // S1016 if r.Type == 9 { g, e := loadDispatchBox(db, r.ID, map[uint64]bool{}) if e != nil { @@ -238,7 +238,7 @@ func (d *DispatchDesign) Roll(count uint64, draw func(uint64) (uint64, error)) ( } return nil } - for i := uint64(0); i < count; i++ { + for range count { for _, r := range d.Rewards { if r.Type == 9 { for n := uint64(0); n < r.Count; n++ { diff --git a/go/internal/server/gamedata/equipment_collaboration.go b/go/internal/server/gamedata/equipment_collaboration.go index aad3e92..751ab68 100644 --- a/go/internal/server/gamedata/equipment_collaboration.go +++ b/go/internal/server/gamedata/equipment_collaboration.go @@ -3,7 +3,8 @@ package gamedata import ( "database/sql" "fmt" - "sort" + "maps" + "slices" ) // IncludeCollaborationURWeapons extends only the UR equipment ticket pool. @@ -79,17 +80,13 @@ func (c *EquipmentGachaCatalog) includeCollaborationURWeapons(db *sql.DB) error targets = append(targets, id) } } - sort.Slice(targets, func(i, j int) bool { return targets[i] < targets[j] }) + slices.Sort(targets) // Publish only after all selected products validate successfully. clone := *c clone.Gachas = make(map[uint64]EquipmentGacha, len(c.Gachas)) - for id, g := range c.Gachas { - clone.Gachas[id] = g - } + maps.Copy(clone.Gachas, c.Gachas) clone.equipment = make(map[uint64]EquipmentDesign, len(c.equipment)) - for id, d := range c.equipment { - clone.equipment[id] = d - } + maps.Copy(clone.equipment, c.equipment) for _, id := range targets { if err := clone.includeCollaborationWeaponPool(db, id); err != nil { return err @@ -106,7 +103,7 @@ func (c *EquipmentGachaCatalog) includeCollaborationWeaponPool(db *sql.DB, id ui if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() out := map[uint64][]byte{} for rows.Next() { var id uint64 @@ -188,7 +185,7 @@ func (c *EquipmentGachaCatalog) includeCollaborationWeaponPool(db *sql.DB, id ui for id := range equipment { ids = append(ids, id) } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) for _, id := range ids { raw := equipment[id] grade, _ := packedInts(raw, 3) @@ -212,9 +209,7 @@ func (c *EquipmentGachaCatalog) includeCollaborationWeaponPool(db *sql.DB, id ui pool[index].Children = append(pool[index].Children, WeightedEquipment{ID: id, Weight: 1}) seen[id] = true } - for id, design := range designs { - c.equipment[id] = design - } + maps.Copy(c.equipment, designs) g.Pool = pool g.Grades = make(map[uint64]uint64, len(seen)) for id := range seen { diff --git a/go/internal/server/gamedata/equipment_collaboration_test.go b/go/internal/server/gamedata/equipment_collaboration_test.go index ac8aaf9..837f2f2 100644 --- a/go/internal/server/gamedata/equipment_collaboration_test.go +++ b/go/internal/server/gamedata/equipment_collaboration_test.go @@ -3,6 +3,7 @@ package gamedata import ( "database/sql" "encoding/binary" + "maps" "os" "reflect" "testing" @@ -18,9 +19,7 @@ func TestIncludeCollaborationURWeaponsInstalled(t *testing.T) { t.Fatal(err) } before := map[uint64]EquipmentGacha{} - for id, g := range c.Gachas { - before[id] = g - } + maps.Copy(before, c.Gachas) if err := c.IncludeCollaborationURWeapons(root, "20260923193640"); err != nil { t.Fatal(err) } @@ -84,7 +83,11 @@ func TestIncludeCollaborationURWeaponsSynthetic(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() db.SetMaxOpenConns(1) for _, schema := range []string{"CREATE TABLE LimitedCostumeTable(id INTEGER)", "CREATE TABLE CostumeTable(id INTEGER,useUniqueCharId INTEGER,ProtoBuf BLOB)", "CREATE TABLE CharTable(id INTEGER,uniqueCharId INTEGER,ProtoBuf BLOB)", "CREATE TABLE EquipmentTable(id INTEGER,privateUniqueCharId INTEGER,ProtoBuf BLOB)", "CREATE TABLE EquipmentOptionTable(id INTEGER,GroupId INTEGER,ProtoBuf BLOB)"} { if _, err := db.Exec(schema); err != nil { @@ -169,7 +172,11 @@ func TestEquipmentFixedUsesChangedIDThresholdsAndResetDefault(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err := db.Exec("CREATE TABLE GachaFixedTable(id INTEGER,ProtoBuf BLOB)"); err != nil { t.Fatal(err) } @@ -248,7 +255,11 @@ func TestEquipmentGachaLoaderAcceptsChangedPriceAndPoolLength(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() for _, table := range []string{"GachaTable", "RewardGroupTable"} { if _, err := db.Exec("CREATE TABLE " + table + "(id INTEGER,ProtoBuf BLOB)"); err != nil { t.Fatal(err) diff --git a/go/internal/server/gamedata/equipment_craft.go b/go/internal/server/gamedata/equipment_craft.go index 709c668..1a8f52b 100644 --- a/go/internal/server/gamedata/equipment_craft.go +++ b/go/internal/server/gamedata/equipment_craft.go @@ -69,13 +69,13 @@ func loadEquipmentCraftDesign(db *sql.DB) (*EquipmentCraftDesign, error) { for rows.Next() { var row makingRow if err := rows.Scan(&row.id, &row.raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } makingRows = append(makingRows, row) } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if err := rows.Close(); err != nil { @@ -131,7 +131,7 @@ func loadEquipmentCraftDesign(db *sql.DB) (*EquipmentCraftDesign, error) { var id uint64 var raw []byte if err := rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } classes, _ := packedInts(raw, 4) @@ -141,6 +141,10 @@ func loadEquipmentCraftDesign(db *sql.DB) (*EquipmentCraftDesign, error) { talents[id] = equipmentMakingTalent{SkillGroup: skillGroups[0], GrowthGroup: growthGroups[0]} } } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } if err := rows.Close(); err != nil { return nil, err } @@ -152,7 +156,7 @@ func loadEquipmentCraftDesign(db *sql.DB) (*EquipmentCraftDesign, error) { var id uint64 var raw []byte if err := rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } talentIDs, _ := packedInts(raw, 18) @@ -162,6 +166,10 @@ func loadEquipmentCraftDesign(db *sql.DB) (*EquipmentCraftDesign, error) { } } } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } if err := rows.Close(); err != nil { return nil, err } @@ -173,13 +181,13 @@ func loadEquipmentCraftDesign(db *sql.DB) (*EquipmentCraftDesign, error) { var group, level uint64 var raw []byte if err := rows.Scan(&group, &level, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } catalysts, _ := packedInts(raw, 1) experience, _ := packedInts(raw, 5) if len(catalysts) > 1 || len(experience) > 1 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment making talent skill %d/%d malformed", group, level) } var skill equipmentMakingTalentSkill @@ -191,6 +199,10 @@ func loadEquipmentCraftDesign(db *sql.DB) (*EquipmentCraftDesign, error) { } design.Skills[[2]uint64{group, level}] = skill } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } if err := rows.Close(); err != nil { return nil, err } @@ -202,12 +214,12 @@ func loadEquipmentCraftDesign(db *sql.DB) (*EquipmentCraftDesign, error) { var group, level uint64 var raw []byte if err := rows.Scan(&group, &level, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } need, err := packedInts(raw, 6) if err != nil || len(need) > 1 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: malformed equipment talent growth %d/%d", group, level) } design.TalentNeed[[2]uint64{group, level}] = 0 @@ -215,6 +227,10 @@ func loadEquipmentCraftDesign(db *sql.DB) (*EquipmentCraftDesign, error) { design.TalentNeed[[2]uint64{group, level}] = need[0] } } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } if err := rows.Close(); err != nil { return nil, err } diff --git a/go/internal/server/gamedata/equipment_gacha.go b/go/internal/server/gamedata/equipment_gacha.go index a3de3bc..9d13083 100644 --- a/go/internal/server/gamedata/equipment_gacha.go +++ b/go/internal/server/gamedata/equipment_gacha.go @@ -140,7 +140,7 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme var id uint64 var raw []byte if err := rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if _, exists := c.Gachas[id]; exists { @@ -157,7 +157,7 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme candidates = append(candidates, ticketCandidate{id, count[0], reward[0], append([]uint64(nil), tickets...)}) } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if err := rows.Close(); err != nil { @@ -167,7 +167,6 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme id := candidate.id pool, equipmentOnly, err := classifyEquipmentRewardPool(db, candidate.reward) if err != nil { - rows.Close() return nil, fmt.Errorf("gamedata: ticket gacha %d: %w", id, err) } if !equipmentOnly { @@ -177,18 +176,10 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme c.Gachas[id] = g for _, item := range pool { if err := c.loadEquipmentTree(db, item); err != nil { - rows.Close() return nil, err } } } - if err := rows.Err(); err != nil { - rows.Close() - return nil, err - } - if err := rows.Close(); err != nil { - return nil, err - } for id, g := range c.Gachas { g.Grades = map[uint64]uint64{} var visit func([]WeightedEquipment) @@ -392,17 +383,21 @@ func loadOptionGroups(db *sql.DB, ids []uint64) ([]OptionGroup, error) { for rows.Next() { var raw []byte if err := rows.Scan(&raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } id, _ := packedInts(raw, 5) weight, _ := packedInts(raw, 2) if len(id) != 1 || len(weight) != 1 || weight[0] == 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: malformed equipment option group %d", groupID) } g.Choices = append(g.Choices, WeightedOption{ID: id[0], Weight: weight[0]}) } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } if err := rows.Close(); err != nil { return nil, err } @@ -574,17 +569,6 @@ func (g EquipmentGacha) rollWith(sr, ur uint64, fixed EquipmentFixedDesign, draw return out, state, nil } -func containsEquipment(item WeightedEquipment, id uint64) bool { - if item.ID != 0 { - return item.ID == id - } - for _, child := range item.Children { - if containsEquipment(child, id) { - return true - } - } - return false -} func rollEquipmentChoiceWith(pool []WeightedEquipment, draw func(uint64) (uint64, error)) (uint64, error) { var total uint64 for _, item := range pool { diff --git a/go/internal/server/gamedata/equipment_option_reroll.go b/go/internal/server/gamedata/equipment_option_reroll.go index 2ec4932..3a2810e 100644 --- a/go/internal/server/gamedata/equipment_option_reroll.go +++ b/go/internal/server/gamedata/equipment_option_reroll.go @@ -79,7 +79,7 @@ func loadEquipmentOptionRerollDesign(db *sql.DB) (*EquipmentOptionRerollDesign, var id uint64 var proto []byte if err := rows.Scan(&id, &proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } protoID, idErr := packedInts(proto, 6) @@ -90,7 +90,7 @@ func loadEquipmentOptionRerollDesign(db *sql.DB) (*EquipmentOptionRerollDesign, privateUniqueCharID, uniqueErr := packedInts(proto, 16) if idErr != nil || rerollErr != nil || mainErr != nil || subErr != nil || privateErr != nil || uniqueErr != nil || len(protoID) != 1 || protoID[0] != id || len(rerollID) != 1 || rerollID[0] == 0 || len(main) == 0 || len(sub) == 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment %d has invalid option-reroll design", id) } item := EquipmentOptionRerollItem{ @@ -101,7 +101,7 @@ func loadEquipmentOptionRerollDesign(db *sql.DB) (*EquipmentOptionRerollDesign, PrivateGroups: append([]uint64(nil), private...), } if len(privateUniqueCharID) > 1 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment %d has invalid private unique character", id) } if len(privateUniqueCharID) == 1 { @@ -109,7 +109,7 @@ func loadEquipmentOptionRerollDesign(db *sql.DB) (*EquipmentOptionRerollDesign, } for _, group := range append(append(append([]uint64(nil), main...), sub...), private...) { if group == 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment %d references option group zero", id) } referencedGroups[group] = true @@ -117,7 +117,7 @@ func loadEquipmentOptionRerollDesign(db *sql.DB) (*EquipmentOptionRerollDesign, d.Equipment[id] = item } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if err := rows.Close(); err != nil { @@ -132,7 +132,7 @@ func loadEquipmentOptionRerollDesign(db *sql.DB) (*EquipmentOptionRerollDesign, var id uint64 var proto []byte if err := rows.Scan(&id, &proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } protoID, idErr := packedInts(proto, 1) @@ -142,13 +142,13 @@ func loadEquipmentOptionRerollDesign(db *sql.DB) (*EquipmentOptionRerollDesign, types, typeErr := packedInts(proto, 5) if idErr != nil || lockErr != nil || countErr != nil || itemErr != nil || typeErr != nil || len(protoID) != 1 || protoID[0] != id || len(counts) == 0 || len(locks) != len(counts) || len(ids) != len(counts) || len(types) != len(counts) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: option-reroll cost %d has mismatched resource arrays", id) } cost := EquipmentOptionRerollCost{Resources: make([]EquipmentOptionRerollResource, len(counts))} for i := range counts { if types[i] == 0 || counts[i] == 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: option-reroll cost %d has invalid resource %d", id, i) } cost.Resources[i] = EquipmentOptionRerollResource{Type: types[i], ID: ids[i], BaseCount: counts[i], LockCount: locks[i]} @@ -156,7 +156,7 @@ func loadEquipmentOptionRerollDesign(db *sql.DB) (*EquipmentOptionRerollDesign, d.Costs[id] = cost } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if err := rows.Close(); err != nil { @@ -171,7 +171,7 @@ func loadEquipmentOptionRerollDesign(db *sql.DB) (*EquipmentOptionRerollDesign, var id uint64 var proto []byte if err := rows.Scan(&id, &proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } ratio, ratioErr := packedInts(proto, 1) @@ -183,7 +183,7 @@ func loadEquipmentOptionRerollDesign(db *sql.DB) (*EquipmentOptionRerollDesign, len(ratio) != 1 || ratio[0] == 0 || len(sourceID) != 1 || sourceID[0] == 0 || len(sourceType) != 1 || sourceType[0] == 0 || len(targetID) != 1 || targetID[0] == 0 || len(targetType) != 1 || targetType[0] == 0 || (sourceID[0] == targetID[0] && sourceType[0] == targetType[0]) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment reroll default %d is invalid", id) } d.Conversion = &EquipmentOptionRerollConversion{ @@ -191,7 +191,7 @@ func loadEquipmentOptionRerollDesign(db *sql.DB) (*EquipmentOptionRerollDesign, } } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if err := rows.Close(); err != nil { @@ -206,7 +206,7 @@ func loadEquipmentOptionRerollDesign(db *sql.DB) (*EquipmentOptionRerollDesign, var groupID, id uint64 var proto []byte if err := rows.Scan(&groupID, &id, &proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if !referencedGroups[groupID] { @@ -218,7 +218,7 @@ func loadEquipmentOptionRerollDesign(db *sql.DB) (*EquipmentOptionRerollDesign, value, present, valueErr := fixed64Double(proto, 1) if groupErr != nil || idErr != nil || weightErr != nil || valueErr != nil || !present || len(protoGroup) != 1 || protoGroup[0] != groupID || len(protoID) != 1 || protoID[0] != id || len(weights) != 1 || weights[0] == 0 || math.IsNaN(value) || math.IsInf(value, 0) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: option group %d choice %d is invalid", groupID, id) } group := d.Groups[groupID] @@ -227,7 +227,7 @@ func loadEquipmentOptionRerollDesign(db *sql.DB) (*EquipmentOptionRerollDesign, d.Groups[groupID] = group } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if err := rows.Close(); err != nil { diff --git a/go/internal/server/gamedata/equipment_option_reroll_test.go b/go/internal/server/gamedata/equipment_option_reroll_test.go index aaf0d0e..e7c3028 100644 --- a/go/internal/server/gamedata/equipment_option_reroll_test.go +++ b/go/internal/server/gamedata/equipment_option_reroll_test.go @@ -11,7 +11,11 @@ import ( func TestEquipmentOptionRerollDesignReadsCostsAndRollsUnlockedSlots(t *testing.T) { db := optionRerollTestDatabase(t) - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() insertOptionRerollEquipment(t, db, 77, 9, []uint64{100}, []uint64{200, 200}, []uint64{300}) insertOptionRerollCost(t, db, 9, []uint64{0, 5}, []uint64{1000, 5}, []uint64{0, 17}, []uint64{4, 8}) @@ -60,7 +64,11 @@ func TestEquipmentOptionRerollDesignReadsCostsAndRollsUnlockedSlots(t *testing.T func TestEquipmentOptionRerollDesignRejectsMismatchedCostArrays(t *testing.T) { db := optionRerollTestDatabase(t) - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() insertOptionRerollEquipment(t, db, 77, 9, []uint64{100}, []uint64{200}, nil) // LockItemCount has one entry but the other parallel arrays have two. insertOptionRerollCost(t, db, 9, []uint64{0}, []uint64{1000, 5}, []uint64{0, 17}, []uint64{4, 8}) @@ -139,7 +147,9 @@ func optionRerollTestDatabase(t *testing.T) *sql.DB { CREATE TABLE EquipmentRerollDefaultTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE EquipmentOptionTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id)); `); err != nil { - db.Close() + if err := db.Close(); err != nil { + t.Error(err) + } t.Fatal(err) } return db diff --git a/go/internal/server/gamedata/equipment_slots.go b/go/internal/server/gamedata/equipment_slots.go index 2408c78..53ee841 100644 --- a/go/internal/server/gamedata/equipment_slots.go +++ b/go/internal/server/gamedata/equipment_slots.go @@ -21,7 +21,7 @@ func loadEquipmentSlots(db *sql.DB) (map[uint64]uint64, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() slots := make(map[uint64]uint64) for rows.Next() { var id uint64 diff --git a/go/internal/server/gamedata/equipment_slots_test.go b/go/internal/server/gamedata/equipment_slots_test.go index 779638e..09e2bf6 100644 --- a/go/internal/server/gamedata/equipment_slots_test.go +++ b/go/internal/server/gamedata/equipment_slots_test.go @@ -10,7 +10,11 @@ func TestLoadEquipmentSlotsKeepsProtoDefaultSlotZero(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err := db.Exec("CREATE TABLE EquipmentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)"); err != nil { t.Fatal(err) } diff --git a/go/internal/server/gamedata/equipment_smelting.go b/go/internal/server/gamedata/equipment_smelting.go index 34194c2..e81c818 100644 --- a/go/internal/server/gamedata/equipment_smelting.go +++ b/go/internal/server/gamedata/equipment_smelting.go @@ -57,20 +57,20 @@ func loadEquipmentSmeltingDesign(db *sql.DB) (*EquipmentSmeltingDesign, error) { var id uint64 var proto []byte if err := rows.Scan(&id, &proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } grade, _ := packedInts(proto, 3) maximum, _ := packedInts(proto, 13) rankGroup, _ := packedInts(proto, 19) if len(grade) != 1 || len(maximum) != 1 || len(rankGroup) != 1 || grade[0] == 0 || maximum[0] == 0 || rankGroup[0] == 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment %d has invalid smelting design", id) } d.Equipment[id] = EquipmentSmeltingItem{Grade: grade[0], RankGroup: rankGroup[0], MaxLevel: maximum[0]} } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if err := rows.Close(); err != nil { @@ -84,26 +84,26 @@ func loadEquipmentSmeltingDesign(db *sql.DB) (*EquipmentSmeltingDesign, error) { var id uint64 var proto []byte if err := rows.Scan(&id, &proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } counts, _ := packedInts(proto, 4) ids, _ := packedInts(proto, 5) types, _ := packedInts(proto, 6) if len(counts) == 0 || len(counts) != len(ids) || len(counts) != len(types) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment grade %d has invalid smelting costs", id) } for i := range counts { if counts[i] == 0 || (types[i] != 4 && types[i] != 8) || (types[i] == 4 && ids[i] != 0) || (types[i] == 8 && ids[i] == 0) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment grade %d has invalid smelting material", id) } d.Grades[id] = append(d.Grades[id], PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]}) } } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if err := rows.Close(); err != nil { @@ -117,36 +117,36 @@ func loadEquipmentSmeltingDesign(db *sql.DB) (*EquipmentSmeltingDesign, error) { var group, slot uint64 var proto []byte if err := rows.Scan(&group, &slot, &proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } ratios, err := fixed32Floats(proto, 4) growth, growthErr := packedInts(proto, 2) values, valueErr := packedInts(proto, 5) if err != nil || growthErr != nil || valueErr != nil || slot < 1 || slot > 3 || len(ratios) != 4 || len(growth) != 4 || len(values) != 4 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment rank %d/%d has invalid smelting distribution", group, slot) } var total float64 for i := range ratios { if ratios[i] < 0 || ratios[i] > 1 || math.IsNaN(ratios[i]) || values[i] == 0 || growth[i] == 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment rank %d/%d has invalid smelting value", group, slot) } total += ratios[i] if i != 0 && (values[i] <= values[i-1] || growth[i] <= growth[i-1]) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment rank %d/%d is not increasing", group, slot) } } if math.Abs(total-1) > 1e-5 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment rank %d/%d ratio total %.8f", group, slot, total) } d.Ranks[[2]uint64{group, slot}] = EquipmentSmeltingRank{GrowthPoint: growth, Values: values, Ratio: ratios} } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if err := rows.Close(); err != nil { diff --git a/go/internal/server/gamedata/equipment_stats.go b/go/internal/server/gamedata/equipment_stats.go index 8006cea..b6d6884 100644 --- a/go/internal/server/gamedata/equipment_stats.go +++ b/go/internal/server/gamedata/equipment_stats.go @@ -34,7 +34,7 @@ func loadEquipmentStatDesign(db *sql.DB) (*EquipmentStatDesign, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() d := &EquipmentStatDesign{Options: map[[2]uint64]EquipmentStatRule{}} for rows.Next() { var group, id uint64 diff --git a/go/internal/server/gamedata/equipment_stats_test.go b/go/internal/server/gamedata/equipment_stats_test.go index 54243b1..ce7707f 100644 --- a/go/internal/server/gamedata/equipment_stats_test.go +++ b/go/internal/server/gamedata/equipment_stats_test.go @@ -41,7 +41,11 @@ func TestEquipmentStatLoaderReadsCompositeKeysAndFloatCurves(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err = db.Exec("CREATE TABLE EquipmentOptionTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)"); err != nil { t.Fatal(err) } diff --git a/go/internal/server/gamedata/equipment_upgrade.go b/go/internal/server/gamedata/equipment_upgrade.go index 2940cd4..515efd1 100644 --- a/go/internal/server/gamedata/equipment_upgrade.go +++ b/go/internal/server/gamedata/equipment_upgrade.go @@ -52,7 +52,7 @@ func loadEquipmentUpgradeDesign(db *sql.DB) (*EquipmentUpgradeDesign, error) { var id uint64 var proto []byte if err := rows.Scan(&id, &proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } groups, _ := packedInts(proto, 4) @@ -60,18 +60,22 @@ func loadEquipmentUpgradeDesign(db *sql.DB) (*EquipmentUpgradeDesign, error) { notTrash, _ := packedInts(proto, 14) rankGroup, _ := packedInts(proto, 19) if len(groups) != 1 || len(maximum) != 1 || len(rankGroup) != 1 || maximum[0] == 0 || rankGroup[0] == 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment %d invalid upgrade design", id) } d.Group[id], d.MaxLevel[id], d.RankGroup[id] = groups[0], maximum[0], rankGroup[0] d.NotTrash[id] = len(notTrash) == 1 && notTrash[0] != 0 rankGroups[rankGroup[0]] = true if prior, found := groupMaximum[groups[0]]; found && prior != maximum[0] { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment growth group %d has inconsistent maxima", groups[0]) } groupMaximum[groups[0]] = maximum[0] } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } if err := rows.Close(); err != nil { return nil, err } @@ -83,7 +87,7 @@ func loadEquipmentUpgradeDesign(db *sql.DB) (*EquipmentUpgradeDesign, error) { var group, level uint64 var proto []byte if err := rows.Scan(&group, &level, &proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } maximum, referenced := groupMaximum[group] @@ -94,13 +98,13 @@ func loadEquipmentUpgradeDesign(db *sql.DB) (*EquipmentUpgradeDesign, error) { breakIDs, _ := packedInts(proto, 2) breakTypes, _ := packedInts(proto, 3) if len(breakCounts) == 0 || len(breakCounts) != len(breakIDs) || len(breakCounts) != len(breakTypes) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment break result %d/%d malformed", group, level) } breakRewards := make([]BattleReward, 0, len(breakCounts)) for i := range breakCounts { if breakCounts[i] == 0 || breakIDs[i] == 0 || breakTypes[i] == 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment break result %d/%d invalid", group, level) } breakRewards = append(breakRewards, BattleReward{Type: breakTypes[i], ID: breakIDs[i], Count: breakCounts[i]}) @@ -117,19 +121,23 @@ func loadEquipmentUpgradeDesign(db *sql.DB) (*EquipmentUpgradeDesign, error) { point, _ := packedInts(proto, 5) ratio, present, err := fixed64Double(proto, 10) if err != nil || !present || ratio < 0 || ratio > 1 || len(point) != 1 || len(counts) == 0 || len(counts) != len(ids) || len(counts) != len(types) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment growth %d/%d malformed", group, level) } entry := EquipmentUpgradeLevel{Level: level, GrowthPoint: point[0], SuccessRatio: ratio} for i := range counts { if counts[i] == 0 || (types[i] != 4 && types[i] != 8) || (types[i] == 4 && ids[i] != 0) || (types[i] == 8 && ids[i] == 0) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment growth %d/%d invalid cost", group, level) } entry.Costs = append(entry.Costs, PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]}) } d.Levels[[2]uint64{group, level}] = entry } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } if err := rows.Close(); err != nil { return nil, err } @@ -141,7 +149,7 @@ func loadEquipmentUpgradeDesign(db *sql.DB) (*EquipmentUpgradeDesign, error) { var group, slot uint64 var proto []byte if err := rows.Scan(&group, &slot, &proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if !rankGroups[group] { @@ -149,23 +157,27 @@ func loadEquipmentUpgradeDesign(db *sql.DB) (*EquipmentUpgradeDesign, error) { } ratios, err := fixed32Floats(proto, 4) if err != nil || slot < 1 || slot > 3 || len(ratios) != 4 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment rank %d/%d malformed", group, slot) } var total float64 for _, ratio := range ratios { if ratio < 0 || ratio > 1 || math.IsNaN(ratio) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment rank %d/%d invalid ratio", group, slot) } total += ratio } if math.Abs(total-1) > 1e-5 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: equipment rank %d/%d ratio total %.8f", group, slot, total) } d.RankRatio[[2]uint64{group, slot}] = append([]float64(nil), ratios...) } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } if err := rows.Close(); err != nil { return nil, err } diff --git a/go/internal/server/gamedata/equipment_upgrade_test.go b/go/internal/server/gamedata/equipment_upgrade_test.go index e2de29a..a720d05 100644 --- a/go/internal/server/gamedata/equipment_upgrade_test.go +++ b/go/internal/server/gamedata/equipment_upgrade_test.go @@ -15,7 +15,11 @@ func TestEquipmentUpgradeDesignReadsCostsAndRatio(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err := db.Exec("CREATE TABLE EquipmentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE EquipmentGrowthTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id)); CREATE TABLE EquipmentRankTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))"); err != nil { t.Fatal(err) } diff --git a/go/internal/server/gamedata/event_actions.go b/go/internal/server/gamedata/event_actions.go index ef2275b..a5ab718 100644 --- a/go/internal/server/gamedata/event_actions.go +++ b/go/internal/server/gamedata/event_actions.go @@ -45,12 +45,12 @@ func LoadEventActionsDesign(root, version string) (*EventActionsDesign, error) { for rows.Next() { var p []byte if e = rows.Scan(&p); e != nil { - rows.Close() + _ = rows.Close() return nil, e } r := EventActionRow{Values: map[int]uint64{}, Text: map[int]string{}} if e = wire.Walk(p, func(f wire.Field) error { - if f.Type == 0 { + if f.Type == 0 { //nolint:staticcheck // QF1003 v, _, er := wire.Varint(p, f.Number) if er != nil { return er @@ -61,7 +61,7 @@ func LoadEventActionsDesign(root, version string) (*EventActionsDesign, error) { } return nil }); e != nil { - rows.Close() + _ = rows.Close() return nil, e } var array [3]int @@ -74,21 +74,21 @@ func LoadEventActionsDesign(root, version string) (*EventActionsDesign, error) { if array[0] > 0 { rs, e := monsterHuntRewardArrays(p, array[0], array[1], array[2]) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } for _, x := range rs { - r.Rewards = append(r.Rewards, Reward{x.Type, x.ID, x.Count}) + r.Rewards = append(r.Rewards, Reward{x.Type, x.ID, x.Count}) //nolint:staticcheck // S1016 } } if table == "FriendshipSpecialEpisodeTable" { rs, e := monsterHuntRewardArrays(p, 4, 3, 2) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } for _, x := range rs { - r.EventRewards = append(r.EventRewards, Reward{x.Type, x.ID, x.Count}) + r.EventRewards = append(r.EventRewards, Reward{x.Type, x.ID, x.Count}) //nolint:staticcheck // S1016 } } if table == "FireworksTable" { @@ -100,7 +100,7 @@ func LoadEventActionsDesign(root, version string) (*EventActionsDesign, error) { d.Tables[table] = append(d.Tables[table], r) } e = rows.Err() - rows.Close() + _ = rows.Close() if e != nil { return nil, e } diff --git a/go/internal/server/gamedata/event_battle_challenge.go b/go/internal/server/gamedata/event_battle_challenge.go index 4c46b06..10521cd 100644 --- a/go/internal/server/gamedata/event_battle_challenge.go +++ b/go/internal/server/gamedata/event_battle_challenge.go @@ -22,7 +22,7 @@ func LoadEventBattleChallenges(root, version string) (EventBattleChallenges, err if e != nil { return nil, e } - defer rows.Close() + defer func() { _ = rows.Close() }() out := EventBattleChallenges{} for rows.Next() { var id uint64 @@ -69,7 +69,7 @@ func VerifySubmittedChallenges(req []byte, definitions []EventBattleChallenge) ( } var v []uint64 var e error - if f.Type == 0 { + if f.Type == 0 { //nolint:staticcheck // QF1003 n, k := binary.Uvarint(f.Value) if k <= 0 { return wire.ErrMalformed diff --git a/go/internal/server/gamedata/event_exchange.go b/go/internal/server/gamedata/event_exchange.go index 12cebac..d851fcc 100644 --- a/go/internal/server/gamedata/event_exchange.go +++ b/go/internal/server/gamedata/event_exchange.go @@ -29,39 +29,39 @@ func LoadEventExchangeCatalog(root, version string) (*EventExchangeCatalog, erro var id uint64 var raw []byte if err = rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } g := EventExchangeGroup{ID: id} for n, p := range map[int]*uint64{22: &g.StartPage, 4: &g.EndPage, 7: &g.FreeCount, 8: &g.FreeType, 25: &g.UnlockRatio, 14: &g.Cost.Count, 15: &g.Cost.ID, 16: &g.Cost.Type} { *p, err = optionalScalar(raw, n) if err != nil { - rows.Close() + _ = rows.Close() return nil, err } } repeat, e := optionalScalar(raw, 12) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } g.Repeat = repeat != 0 if g.StartPage == 0 || g.EndPage < g.StartPage || g.Cost.Count == 0 || g.Cost.Type == 0 || g.FreeType > 2 || g.ID > 2147483647 || g.EndPage > 2147483647 || g.Cost.Count > 2147483647 || g.FreeCount > 2147483647 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid exchange group %d", id) } d.Groups[id] = g } if err = rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } - rows.Close() + _ = rows.Close() rows, err = db.Query("SELECT groupId,id,pageId,ProtoBuf FROM EventCoinExchangeTable ORDER BY groupId,id") if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var group, id, page uint64 var raw []byte diff --git a/go/internal/server/gamedata/event_field_pack.go b/go/internal/server/gamedata/event_field_pack.go index ef5d3c5..dc90839 100644 --- a/go/internal/server/gamedata/event_field_pack.go +++ b/go/internal/server/gamedata/event_field_pack.go @@ -29,12 +29,12 @@ func loadEventFieldPacks(db *sql.DB) (map[int]EventFieldPack, error) { var id int var raw []byte if err = rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } kind, e := optionalScalar(raw, 55) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } if kind != 100 { @@ -42,30 +42,30 @@ func loadEventFieldPacks(db *sql.DB) (map[int]EventFieldPack, error) { } protoID, e := optionalScalar(raw, 25) if e != nil || protoID != uint64(id) || id <= 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid hidden pack %d", id) } p := EventFieldPack{ID: id, InitialPosition: "{}"} if p.BuyType, err = optionalScalar(raw, 11); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if p.BuyPrice, err = optionalScalar(raw, 7); err != nil { - rows.Close() + _ = rows.Close() return nil, err } rewards, e := eventGameRewards(raw, 10, 9, 8) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } for _, r := range rewards { - p.BuyRewards = append(p.BuyRewards, Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + p.BuyRewards = append(p.BuyRewards, Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 } packs[id] = p } err = rows.Err() - rows.Close() + _ = rows.Close() if err != nil { return nil, err } @@ -73,7 +73,7 @@ func loadEventFieldPacks(db *sql.DB) (map[int]EventFieldPack, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var id, packID int if err = rows.Scan(&id, &packID); err != nil { diff --git a/go/internal/server/gamedata/event_games.go b/go/internal/server/gamedata/event_games.go index 80a0ec5..2421e9b 100644 --- a/go/internal/server/gamedata/event_games.go +++ b/go/internal/server/gamedata/event_games.go @@ -54,7 +54,7 @@ func LoadEventGame(root, version string, kind, id uint64) (*EventGame, error) { if e != nil { return nil, e } - defer rows.Close() + defer func() { _ = rows.Close() }() var out []EventGameReward for rows.Next() { var b []byte @@ -139,20 +139,25 @@ func LoadEventGame(root, version string, kind, id uint64) (*EventGame, error) { for rows.Next() { var b []byte if e = rows.Scan(&b); e != nil { - rows.Close() + _ = rows.Close() return nil, e } i, e := optionalScalar(b, 3) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } min, _ := optionalScalar(b, 5) max, _ := optionalScalar(b, 4) d.Moves = append(d.Moves, struct{ ID, Min, Max uint64 }{i, min, max}) } - e = rows.Err() - rows.Close() + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } case 13: d.Cells, e = load("BingoRewardGroupTable", cellGroup, "default") if e == nil { diff --git a/go/internal/server/gamedata/event_owned_item.go b/go/internal/server/gamedata/event_owned_item.go index 9caf89e..01a82cf 100644 --- a/go/internal/server/gamedata/event_owned_item.go +++ b/go/internal/server/gamedata/event_owned_item.go @@ -16,16 +16,16 @@ func LoadOwnedEventItemDesign(root, version string) (map[uint64]map[uint64]bool, for rows.Next() { var id uint64 if e = rows.Scan(&id); e != nil { - rows.Close() + _ = rows.Close() return nil, e } out[typ][id] = true } if e = rows.Err(); e != nil { - rows.Close() + _ = rows.Close() return nil, e } - rows.Close() + _ = rows.Close() } return out, nil } diff --git a/go/internal/server/gamedata/event_play.go b/go/internal/server/gamedata/event_play.go index 99fc2ac..99f6126 100644 --- a/go/internal/server/gamedata/event_play.go +++ b/go/internal/server/gamedata/event_play.go @@ -33,13 +33,13 @@ func LoadEventPlayCatalog(root, version string) (*EventPlayCatalog, error) { for rows.Next() { var raw []byte if e = rows.Scan(&raw); e != nil { - rows.Close() + _ = rows.Close() return nil, e } c.Tables[name] = append(c.Tables[name], append([]byte(nil), raw...)) } e = rows.Err() - rows.Close() + _ = rows.Close() if e != nil { return nil, e } diff --git a/go/internal/server/gamedata/event_play_field.go b/go/internal/server/gamedata/event_play_field.go index 771c0b3..314328c 100644 --- a/go/internal/server/gamedata/event_play_field.go +++ b/go/internal/server/gamedata/event_play_field.go @@ -31,7 +31,7 @@ func (c *EventPlayCatalog) Field(game uint64) (*EventField, error) { if e != nil { return nil, e } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { if e = rows.Scan(&raw); e != nil { return nil, e diff --git a/go/internal/server/gamedata/event_tasks.go b/go/internal/server/gamedata/event_tasks.go index 21f8f22..cf49c16 100644 --- a/go/internal/server/gamedata/event_tasks.go +++ b/go/internal/server/gamedata/event_tasks.go @@ -64,7 +64,7 @@ func loadEventTasksDesign(db *sql.DB) (*EventTasksDesign, error) { if e != nil { return e } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var p []byte if e = rows.Scan(&p); e != nil { @@ -217,7 +217,7 @@ func loadEventTasksDesign(db *sql.DB) (*EventTasksDesign, error) { return e } for _, v := range rs { - r.Rewards = append(r.Rewards, Reward{v.Type, v.ID, v.Count}) + r.Rewards = append(r.Rewards, Reward{v.Type, v.ID, v.Count}) //nolint:staticcheck // S1016 } d.PassBuys[g] = append(d.PassBuys[g], r) return nil diff --git a/go/internal/server/gamedata/field_action_objects.go b/go/internal/server/gamedata/field_action_objects.go index 1a6768f..837958e 100644 --- a/go/internal/server/gamedata/field_action_objects.go +++ b/go/internal/server/gamedata/field_action_objects.go @@ -30,26 +30,26 @@ func loadFieldActionObjects(db *sql.DB) (map[int]FieldActionObject, error) { var id int var raw []byte if err = rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } group := FieldActionObject{GroupID: id} for field, dst := range map[int]*int{3: &group.RegenSeconds, 4: &group.Type} { v, e := optionalScalar(raw, field) if e != nil || v > 0x7fffffff { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid action group %d", id) } *dst = int(v) } if id <= 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid action group identity") } groups[id] = group } err = rows.Err() - rows.Close() + _ = rows.Close() if err != nil { return nil, err } @@ -57,7 +57,7 @@ func loadFieldActionObjects(db *sql.DB) (map[int]FieldActionObject, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var id int var raw []byte diff --git a/go/internal/server/gamedata/field_buffs.go b/go/internal/server/gamedata/field_buffs.go index d9c37a3..b277fba 100644 --- a/go/internal/server/gamedata/field_buffs.go +++ b/go/internal/server/gamedata/field_buffs.go @@ -37,7 +37,7 @@ func LoadFieldBuffDesign(root, version string) (map[uint64]FieldBuffDesign, erro if e != nil { return nil, e } - defer rows.Close() + defer func() { _ = rows.Close() }() out := map[uint64]FieldBuffDesign{} for rows.Next() { var id uint64 diff --git a/go/internal/server/gamedata/field_monster_maps.go b/go/internal/server/gamedata/field_monster_maps.go index fd978f9..10f24af 100644 --- a/go/internal/server/gamedata/field_monster_maps.go +++ b/go/internal/server/gamedata/field_monster_maps.go @@ -22,13 +22,13 @@ func LoadFieldMonsterMaps(root, version string, pack int) (map[int][]int, error) var id int var scene string if e = rows.Scan(&id, &scene); e != nil { - rows.Close() + _ = rows.Close() return nil, e } maps[scene] = id } e = rows.Err() - rows.Close() + _ = rows.Close() if e != nil { return nil, e } @@ -41,7 +41,7 @@ func LoadFieldMonsterMaps(root, version string, pack int) (map[int][]int, error) if e != nil { return nil, e } - defer rows.Close() + defer func() { _ = rows.Close() }() out := map[int][]int{} for rows.Next() { var b []byte diff --git a/go/internal/server/gamedata/field_monsters.go b/go/internal/server/gamedata/field_monsters.go index cac8aac..c646ae1 100644 --- a/go/internal/server/gamedata/field_monsters.go +++ b/go/internal/server/gamedata/field_monsters.go @@ -36,20 +36,20 @@ func loadFieldMonsters(db *sql.DB) ([]FieldMonsterDesign, error) { var id int var raw []byte if err = rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } q, e1 := packedInts(raw, 6) life, e2 := packedInts(raw, 3) if e1 != nil || e2 != nil || len(q) > 1 || len(life) > 1 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: malformed monster regeneration %d", id) } g := FieldMonsterDesign{GroupID: id} for f, dst := range map[int]*uint64{7: &g.RegenSeconds, 9: &g.ResetType} { v, e := optionalScalar(raw, f) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } *dst = v @@ -62,7 +62,7 @@ func loadFieldMonsters(db *sql.DB) ([]FieldMonsterDesign, error) { } if len(life) > 0 { if life[0] > math.MaxInt32 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid monster lifetime %d", id) } g.LifeSeconds = life[0] @@ -70,15 +70,15 @@ func loadFieldMonsters(db *sql.DB) ([]FieldMonsterDesign, error) { groups[id] = g } if err = rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } - rows.Close() + _ = rows.Close() rows, err = db.Query("SELECT id, ProtoBuf FROM FieldMonsterTable ORDER BY id") if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() var result []FieldMonsterDesign for rows.Next() { var id int diff --git a/go/internal/server/gamedata/field_monsters_test.go b/go/internal/server/gamedata/field_monsters_test.go index 913a48b..6bcc2eb 100644 --- a/go/internal/server/gamedata/field_monsters_test.go +++ b/go/internal/server/gamedata/field_monsters_test.go @@ -43,7 +43,11 @@ func TestFieldMonsterCatalogJoinsRegenerationAndRejectsBrokenReference(t *testin if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() for _, table := range []string{"FieldMonsterTable", "FieldMonsterRegenTable"} { if _, err = db.Exec("CREATE TABLE " + table + " (id INTEGER,ProtoBuf BLOB)"); err != nil { t.Fatal(err) diff --git a/go/internal/server/gamedata/field_objects.go b/go/internal/server/gamedata/field_objects.go index e96d8ba..3d1ec9c 100644 --- a/go/internal/server/gamedata/field_objects.go +++ b/go/internal/server/gamedata/field_objects.go @@ -49,21 +49,21 @@ func loadFieldObjects(db, common *sql.DB) (FieldObjectDesign, error) { var id int var raw []byte if err = rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return design, err } obj := FieldRewardObject{GroupID: id} for _, field := range []int{2, 3} { v, e := packedInts(raw, field) if e != nil || len(v) > 0 { - rows.Close() + _ = rows.Close() return design, fmt.Errorf("gamedata: field object random buff graph %d requires authored selection rules", id) } } for f, target := range map[int]*int{1: &obj.BuffID, 7: &obj.MonsterID, 8: &obj.QuestID, 9: &obj.ResetType, 12: &obj.Type} { v, e := packedInts(raw, f) if e != nil || len(v) > 1 { - rows.Close() + _ = rows.Close() return design, fmt.Errorf("gamedata: malformed field object group %d", id) } if len(v) > 0 { @@ -72,13 +72,13 @@ func loadFieldObjects(db, common *sql.DB) (FieldObjectDesign, error) { } group, e := packedInts(raw, 10) if e != nil || len(group) > 1 { - rows.Close() + _ = rows.Close() return design, fmt.Errorf("gamedata: malformed field reward group %d", id) } if len(group) == 1 { var rewardRaw []byte if err = common.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", group[0]).Scan(&rewardRaw); err != nil { - rows.Close() + _ = rows.Close() return design, err } ids, e1 := packedInts(rewardRaw, 5) @@ -88,7 +88,7 @@ func loadFieldObjects(db, common *sql.DB) (FieldObjectDesign, error) { ratios, e5 := packedInts(rewardRaw, 8) dropType, e6 := packedInts(rewardRaw, 2) if e1 != nil || e2 != nil || e3 != nil || e4 != nil || e5 != nil || e6 != nil { - rows.Close() + _ = rows.Close() return design, fmt.Errorf("gamedata: malformed field loot %d", group[0]) } if len(ids) > 0 && len(ids) == len(types) && len(ids) == len(counts) && len(ratios) == len(ids) && len(drop) <= 1 && len(dropType) <= 1 { @@ -103,26 +103,26 @@ func loadFieldObjects(db, common *sql.DB) (FieldObjectDesign, error) { obj.Rewards = append(obj.Rewards, BattleReward{ID: ids[i], Type: types[i], Count: counts[i]}) } if e := design.validateLoot(common, obj.Rewards); e != nil { - rows.Close() + _ = rows.Close() return design, e } } else { - rows.Close() + _ = rows.Close() return design, fmt.Errorf("gamedata: malformed field loot entries %d", group[0]) } } groups[id] = obj } if err = rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return design, err } - rows.Close() + _ = rows.Close() rows, err = db.Query("SELECT id,ProtoBuf FROM FieldRewardObjectTable") if err != nil { return design, err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var id int var raw []byte @@ -147,7 +147,7 @@ func loadFieldObjects(db, common *sql.DB) (FieldObjectDesign, error) { if err = rows.Err(); err != nil { return design, err } - rows.Close() + _ = rows.Close() design.Actions, err = loadFieldActionObjects(db) return design, err } diff --git a/go/internal/server/gamedata/field_objects_test.go b/go/internal/server/gamedata/field_objects_test.go index ee7831c..a4a6cd2 100644 --- a/go/internal/server/gamedata/field_objects_test.go +++ b/go/internal/server/gamedata/field_objects_test.go @@ -12,12 +12,20 @@ func TestFieldObjectLoadIndependentEquipmentAndRandomBox(t *testing.T) { if err != nil { t.Fatal(err) } - defer pack.Close() + defer func() { + if err := pack.Close(); err != nil { + t.Error(err) + } + }() common, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatal(err) } - defer common.Close() + defer func() { + if err := common.Close(); err != nil { + t.Error(err) + } + }() for _, table := range []string{"FieldRewardObjectGroupTable", "FieldRewardObjectTable"} { if _, err := pack.Exec("CREATE TABLE " + table + "(id INTEGER, ProtoBuf BLOB)"); err != nil { t.Fatal(err) @@ -140,7 +148,7 @@ func TestFieldResetUsesConfiguredBoundary(t *testing.T) { } func TestFieldWeightedDrawCountAndZeroWeight(t *testing.T) { o := FieldRewardObject{DropCount: 2, Rewards: []BattleReward{{Type: 5, ID: 1, Count: 142}, {Type: 5, ID: 2, Count: 71}}, Ratios: []uint64{0, 100}} - for i := 0; i < 20; i++ { + for range 20 { got, err := o.Draw() if err != nil || len(got) != 2 || got[0].ID != 2 || got[1].ID != 2 { t.Fatalf("draw=%+v err=%v", got, err) diff --git a/go/internal/server/gamedata/field_pack.go b/go/internal/server/gamedata/field_pack.go index e0e39ec..e22dcfe 100644 --- a/go/internal/server/gamedata/field_pack.go +++ b/go/internal/server/gamedata/field_pack.go @@ -33,14 +33,14 @@ func loadFieldPacks(db *sql.DB) (map[int]FieldPack, error) { var id int var raw []byte if err := rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } values := map[int]uint64{} for _, field := range []int{7, 25, 55, 65} { v, e := packedInts(raw, field) if e != nil || len(v) > 1 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid arena pack %d field %d", id, field) } if len(v) == 1 { @@ -51,13 +51,13 @@ func loadFieldPacks(db *sql.DB) (map[int]FieldPack, error) { continue } if id <= 0 || values[25] != uint64(id) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid field pack identity %d", id) } result[id] = FieldPack{ID: uint64(id), Type: values[55], BuyPrice: values[7], UseSchedule: values[65], MapIDs: map[int]bool{}} } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if err := rows.Close(); err != nil { @@ -71,7 +71,7 @@ func loadFieldPacks(db *sql.DB) (map[int]FieldPack, error) { var id int var raw []byte if err := rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } pack, exists := result[id] @@ -82,7 +82,7 @@ func loadFieldPacks(db *sql.DB) (map[int]FieldPack, error) { for field, target := range map[int]*uint64{5: &pack.SquadLevel, 6: &pack.TicketID} { v, e := packedInts(raw, field) if e != nil || len(v) > 1 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid field pack opening %d", id) } if len(v) == 1 { @@ -92,7 +92,7 @@ func loadFieldPacks(db *sql.DB) (map[int]FieldPack, error) { result[id] = pack } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if err := rows.Close(); err != nil { @@ -105,7 +105,7 @@ func loadFieldPacks(db *sql.DB) (map[int]FieldPack, error) { for rows.Next() { var id, packID int if err := rows.Scan(&id, &packID); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if pack, exists := result[packID]; exists { @@ -113,7 +113,7 @@ func loadFieldPacks(db *sql.DB) (map[int]FieldPack, error) { } } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if err := rows.Close(); err != nil { diff --git a/go/internal/server/gamedata/field_pack_test.go b/go/internal/server/gamedata/field_pack_test.go index bd19a20..fca907a 100644 --- a/go/internal/server/gamedata/field_pack_test.go +++ b/go/internal/server/gamedata/field_pack_test.go @@ -12,7 +12,11 @@ func TestFieldPacksLoadMetadataMapsAndEntryRestrictions(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() for _, query := range []string{"CREATE TABLE PackTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE ContentOpenTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)", "CREATE TABLE MapTable(id INTEGER,packId INTEGER)"} { if _, err := db.Exec(query); err != nil { t.Fatal(err) diff --git a/go/internal/server/gamedata/field_research.go b/go/internal/server/gamedata/field_research.go index 953d152..e661b70 100644 --- a/go/internal/server/gamedata/field_research.go +++ b/go/internal/server/gamedata/field_research.go @@ -32,7 +32,7 @@ func LoadFieldResearch(root, version string, pack int) (FieldResearchDesign, err for rows.Next() { var raw []byte if err = rows.Scan(&raw); err != nil { - rows.Close() + _ = rows.Close() return d, err } var o FieldResearchObject @@ -40,39 +40,39 @@ func LoadFieldResearch(root, version string, pack int) (FieldResearchDesign, err for i, f := range []int{1, 3, 5, 17, 18} { v, e := optionalScalar(raw, f) if e != nil { - rows.Close() + _ = rows.Close() return d, e } *values[i] = int(v) } o.QuestRange, err = packedInts(raw, 10) if err != nil { - rows.Close() + _ = rows.Close() return d, err } o.Reward.Type, err = optionalScalar(raw, 16) if err != nil { - rows.Close() + _ = rows.Close() return d, err } o.Reward.ID, err = optionalScalar(raw, 15) if err != nil { - rows.Close() + _ = rows.Close() return d, err } o.Reward.Count, err = optionalScalar(raw, 14) if err != nil { - rows.Close() + _ = rows.Close() return d, err } if o.ID <= 0 { - rows.Close() + _ = rows.Close() return d, fmt.Errorf("gamedata: invalid research id") } d.Objects[o.ID] = o } err = rows.Err() - rows.Close() + _ = rows.Close() if err != nil { return d, err } @@ -90,13 +90,13 @@ func LoadFieldResearch(root, version string, pack int) (FieldResearchDesign, err var id int var scene string if err = rows.Scan(&id, &scene); err != nil { - rows.Close() + _ = rows.Close() return d, err } maps[scene] = id } err = rows.Err() - rows.Close() + _ = rows.Close() if err != nil { return d, err } @@ -109,7 +109,7 @@ func LoadFieldResearch(root, version string, pack int) (FieldResearchDesign, err if err != nil { return d, err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var raw []byte if err = rows.Scan(&raw); err != nil { @@ -142,12 +142,12 @@ func LoadFieldResearch(root, version string, pack int) (FieldResearchDesign, err if err = rows.Err(); err != nil { return d, err } - rows.Close() + _ = rows.Close() rows, err = common.Query(fmt.Sprintf("SELECT id,ProtoBuf FROM QuestTable%d", pack)) if err != nil { return d, err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var id int var raw []byte @@ -191,13 +191,13 @@ func LoadResearchCharacters(root, version string) (map[uint64]bool, error) { for rows.Next() { var id uint64 if err = rows.Scan(&id); err != nil { - rows.Close() + _ = rows.Close() return nil, err } talents[id] = true } err = rows.Err() - rows.Close() + _ = rows.Close() if err != nil { return nil, err } @@ -206,7 +206,7 @@ func LoadResearchCharacters(root, version string) (map[uint64]bool, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var id uint64 var raw []byte diff --git a/go/internal/server/gamedata/field_settings.go b/go/internal/server/gamedata/field_settings.go index 0291495..8ffaa28 100644 --- a/go/internal/server/gamedata/field_settings.go +++ b/go/internal/server/gamedata/field_settings.go @@ -31,29 +31,29 @@ func LoadFieldSettingsDesign(root, version string) (*FieldSettingsDesign, error) for rows.Next() { var id uint64 if e = rows.Scan(&id, &raw); e != nil { - rows.Close() + _ = rows.Close() return nil, e } if id == 0 || id > 2147483647 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid talent identity") } classes[id], e = optionalScalar(raw, 4) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } } if e = rows.Err(); e != nil { - rows.Close() + _ = rows.Close() return nil, e } - rows.Close() + _ = rows.Close() rows, e = db.Query("SELECT id,ProtoBuf FROM CharTable") if e != nil { return nil, e } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var id uint64 if e = rows.Scan(&id, &raw); e != nil { diff --git a/go/internal/server/gamedata/first_gacha.go b/go/internal/server/gamedata/first_gacha.go index 53d72f9..7862fda 100644 --- a/go/internal/server/gamedata/first_gacha.go +++ b/go/internal/server/gamedata/first_gacha.go @@ -309,8 +309,7 @@ func (d *FirstGachaDesign) roll(draw func(uint64) (uint64, error)) ([]FirstGacha } var out []FirstGachaReward var run func(*FirstGachaRewardGroup) error - var emit func(FirstGachaRewardEntry) error - emit = func(e FirstGachaRewardEntry) error { + emit := func(e FirstGachaRewardEntry) error { for n := uint64(0); n < e.Count; n++ { if e.ItemType == 9 { if err := run(e.Group); err != nil { diff --git a/go/internal/server/gamedata/first_gacha_test.go b/go/internal/server/gamedata/first_gacha_test.go index 32e5708..efee228 100644 --- a/go/internal/server/gamedata/first_gacha_test.go +++ b/go/internal/server/gamedata/first_gacha_test.go @@ -77,7 +77,11 @@ func TestFirstGachaLoaderRejectsNonStarterOrPricedMetadata(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() db.SetMaxOpenConns(1) for _, table := range []string{"GameDefaultTable", "GachaGroupTable", "GachaTable"} { if _, err := db.Exec("CREATE TABLE " + table + " (id INTEGER PRIMARY KEY,ProtoBuf BLOB)"); err != nil { @@ -144,7 +148,7 @@ func TestFirstGachaAgainstInstalledVersion23510(t *testing.T) { } // Current GameData gives one five-star, one four-star, three three-star // costumes and five exclusive-equipment instances in every reroll. - for i := 0; i < 20; i++ { + for range 20 { roll, err := design.Roll() if err != nil { t.Fatal(err) diff --git a/go/internal/server/gamedata/food.go b/go/internal/server/gamedata/food.go index b3a623f..e961d0e 100644 --- a/go/internal/server/gamedata/food.go +++ b/go/internal/server/gamedata/food.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math" + "slices" ) // Food is decoded from FoodTable, including materials so that the server can @@ -30,7 +31,7 @@ func loadFoodDesign(db *sql.DB) (*FoodDesign, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() design := &FoodDesign{Foods: map[uint64]Food{}} for rows.Next() { var id uint64 @@ -91,13 +92,8 @@ func (f Food) Recovery(characterID, maximum, count uint64) (uint64, error) { return 0, errors.New("gamedata: food is not a supported recovery dish") } point := f.Point - if f.Type == 1 { - for _, favorite := range f.FavoriteUniqueCharIDs { - if favorite == characterID/10 { - point = f.FavoritePoint - break - } - } + if f.Type == 1 && slices.Contains(f.FavoriteUniqueCharIDs, characterID/10) { + point = f.FavoritePoint } if point == 0 { return 0, errors.New("gamedata: food has no recovery effect") diff --git a/go/internal/server/gamedata/food_test.go b/go/internal/server/gamedata/food_test.go index 377d404..0645bd9 100644 --- a/go/internal/server/gamedata/food_test.go +++ b/go/internal/server/gamedata/food_test.go @@ -34,7 +34,11 @@ func TestFoodLoaderReadsFavoriteIDsAndProtoDefaults(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err = db.Exec("CREATE TABLE FoodTable(id INTEGER PRIMARY KEY,ProtoBuf BLOB)"); err != nil { t.Fatal(err) } diff --git a/go/internal/server/gamedata/friendship.go b/go/internal/server/gamedata/friendship.go index c246129..96f1052 100644 --- a/go/internal/server/gamedata/friendship.go +++ b/go/internal/server/gamedata/friendship.go @@ -3,6 +3,7 @@ package gamedata import ( "database/sql" "fmt" + "slices" ) // FriendshipKey identifies a costume's level or counseling session. @@ -27,10 +28,8 @@ func (g FriendshipGiftDesign) Experience(costumeID uint64) uint64 { if g.GiftType == 1 { return g.FavoriteEXP } - for _, id := range g.FavoriteCostumeIDs { - if id == costumeID { - return g.FavoriteEXP - } + if slices.Contains(g.FavoriteCostumeIDs, costumeID) { + return g.FavoriteEXP } return g.EXP } @@ -185,19 +184,19 @@ func loadFriendshipDesign(db *sql.DB) (FriendshipDesign, error) { for rows.Next() { var selection []byte if err := rows.Scan(&selection); err != nil { - rows.Close() + _ = rows.Close() return d, err } choices, err := packedInts(selection, 1) if err != nil { - rows.Close() + _ = rows.Close() return d, err } session.ChoiceCount = uint64(len(choices)) matches++ } err = rows.Err() - rows.Close() + _ = rows.Close() if err != nil { return d, err } @@ -283,7 +282,7 @@ func friendshipRows(db *sql.DB, query string, visit func(uint64, []byte) error) if err != nil { return err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var id uint64 var proto []byte @@ -302,7 +301,7 @@ func friendshipGroupRows(db *sql.DB, query string, visit func(FriendshipKey, []b if err != nil { return err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var key FriendshipKey var proto []byte diff --git a/go/internal/server/gamedata/friendship_test.go b/go/internal/server/gamedata/friendship_test.go index 975132d..6cee7e5 100644 --- a/go/internal/server/gamedata/friendship_test.go +++ b/go/internal/server/gamedata/friendship_test.go @@ -75,7 +75,11 @@ func friendshipTestDB(t *testing.T) *sql.DB { t.Fatal(err) } db.SetMaxOpenConns(1) - t.Cleanup(func() { db.Close() }) + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }) for _, query := range []string{ "CREATE TABLE FriendshipDefaultTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE FriendshipCostumeTable(id INTEGER,ProtoBuf BLOB)", diff --git a/go/internal/server/gamedata/gacha.go b/go/internal/server/gamedata/gacha.go index 14ea352..187e27d 100644 --- a/go/internal/server/gamedata/gacha.go +++ b/go/internal/server/gamedata/gacha.go @@ -6,8 +6,10 @@ import ( "encoding/binary" "errors" "fmt" + "maps" "math" "math/big" + "slices" "sort" _ "modernc.org/sqlite" @@ -208,9 +210,7 @@ func NewRegularGachaCatalog(gachas map[uint64]RegularGacha, characters map[uint6 groups: map[uint64]GachaGroupDesign{}, byGacha: map[uint64]uint64{}, fixed: map[uint64]GachaFixedDesign{}, grades: map[uint64]uint64{}, stepUps: map[uint64]GachaStepUpDesign{}, stepByGacha: map[uint64]GachaStepDesign{}, } - for id, character := range characters { - catalog.characters[id] = character - } + maps.Copy(catalog.characters, characters) for id, gacha := range gachas { if id == 0 || gacha.ID != id || gacha.Count <= 0 || (gacha.PriceType != 2 && gacha.PriceType != 3 && gacha.PriceType != 19) || gacha.Price == 0 { return nil, errors.New("gamedata: invalid regular gacha definition") @@ -371,21 +371,7 @@ func (c *RegularGachaCatalog) CostumeGrade(costumeID uint64) (uint64, bool) { } func (c *RegularGachaCatalog) recordPoolGrades(gacha RegularGacha) { - for id, grade := range gacha.Grades { - c.grades[id] = grade - } -} - -func recordCostumeGrade(grades map[uint64]uint64, item WeightedCostume, grade uint64) { - if item.ID != 0 { - if current := grades[item.ID]; current == 0 || grade > current { - grades[item.ID] = grade - } - return - } - for _, child := range item.Children { - recordCostumeGrade(grades, child, grade) - } + maps.Copy(c.grades, gacha.Grades) } func (c *RegularGachaCatalog) Character(costumeID uint64) (CharacterDesign, bool) { @@ -447,7 +433,7 @@ func (c *RegularGachaCatalog) FiveStarIDs(gachaID uint64) []uint64 { } } visit(gacha.Pool) - sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) + slices.Sort(result) return result } @@ -807,16 +793,6 @@ func costumeRewardCount(group *CostumeRewardGroup) (uint64, error) { return total, nil } -func rollCostumeBranch(pool []WeightedCostume) (int, uint64, error) { - return rollCostumeBranchWith(pool, func(limit uint64) (uint64, error) { - selected, err := rand.Int(rand.Reader, new(big.Int).SetUint64(limit)) - if err != nil { - return 0, err - } - return selected.Uint64(), nil - }) -} - func rollCostumeBranchWith(pool []WeightedCostume, draw func(uint64) (uint64, error)) (int, uint64, error) { if len(pool) == 0 || draw == nil { return 0, 0, errors.New("gamedata: empty costume choice") @@ -845,16 +821,6 @@ func rollCostumeBranchWith(pool []WeightedCostume, draw func(uint64) (uint64, er return 0, 0, errors.New("gamedata: regular gacha branch selection failed") } -func rollCostumeChoice(pool []WeightedCostume) (uint64, error) { - return rollCostumeChoiceWith(pool, func(limit uint64) (uint64, error) { - selected, err := rand.Int(rand.Reader, new(big.Int).SetUint64(limit)) - if err != nil { - return 0, err - } - return selected.Uint64(), nil - }) -} - func rollCostumeChoiceWith(pool []WeightedCostume, draw func(uint64) (uint64, error)) (uint64, error) { if len(pool) == 0 || draw == nil { return 0, errors.New("gamedata: empty costume choice") @@ -927,7 +893,7 @@ func permanentGachaGroupIDs(root, version string) ([]uint64, error) { if err != nil { return nil, fmt.Errorf("gamedata: list permanent gacha groups: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() var result []uint64 for rows.Next() { var id uint64 @@ -1006,8 +972,8 @@ func ClassifyActiveGachaGroups(root, version string, groupIDs []uint64) (costume return nil, nil, fmt.Errorf("gamedata: active gacha group %d has unsupported type %d", id, types[0]) } } - sort.Slice(costume, func(i, j int) bool { return costume[i] < costume[j] }) - sort.Slice(equipment, func(i, j int) bool { return equipment[i] < equipment[j] }) + slices.Sort(costume) + slices.Sort(equipment) return costume, equipment, nil } @@ -1073,7 +1039,7 @@ func LoadRegularCostumeGachaGroups(root, version string, groupIDs, stepUpGroupID for id := range gachaIDs { orderedIDs = append(orderedIDs, id) } - sort.Slice(orderedIDs, func(i, j int) bool { return orderedIDs[i] < orderedIDs[j] }) + slices.Sort(orderedIDs) for _, id := range orderedIDs { var proto []byte if err := db.QueryRow("SELECT ProtoBuf FROM GachaTable WHERE id=?", id).Scan(&proto); err != nil { @@ -1172,7 +1138,7 @@ func loadGachaStepUp(db *sql.DB, groupID uint64) (GachaStepUpDesign, error) { if err != nil { return GachaStepUpDesign{}, err } - defer rows.Close() + defer func() { _ = rows.Close() }() out := GachaStepUpDesign{ID: groupID} for rows.Next() { var id uint64 @@ -1194,6 +1160,7 @@ func loadGachaStepUp(db *sql.DB, groupID uint64) (GachaStepUpDesign, error) { out.Steps = append(out.Steps, step) } if err := rows.Err(); err != nil { + _ = rows.Close() return GachaStepUpDesign{}, err } if err := rows.Close(); err != nil { @@ -1351,7 +1318,7 @@ func (c *RegularGachaCatalog) loadGroupsAndFixed(db *sql.DB) error { if err != nil { return err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var id uint64 var proto []byte @@ -1385,6 +1352,7 @@ func (c *RegularGachaCatalog) loadGroupsAndFixed(db *sql.DB) error { } } if err := rows.Err(); err != nil { + _ = rows.Close() return err } if err := rows.Close(); err != nil { @@ -1473,16 +1441,6 @@ func (c *RegularGachaCatalog) loadStepFixed(db *sql.DB) error { return nil } -func collectCostumeIDs(pool []WeightedCostume, result *[]uint64) { - for _, item := range pool { - if item.ID != 0 { - *result = append(*result, item.ID) - } else { - collectCostumeIDs(item.Children, result) - } - } -} - func loadGachaCharacterDesign(db *sql.DB, costumeID uint64) (CharacterDesign, error) { characterID, family, err := loadCostumeCharacterFamily(db, costumeID) if err != nil { @@ -1563,35 +1521,6 @@ func loadCostumeDesign(db *sql.DB, costumeID uint64) (CharacterDesign, error) { }, nil } -func loadCostumeRewardPool(db *sql.DB, groupID uint64) ([]WeightedCostume, error) { - var proto []byte - if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", groupID).Scan(&proto); err != nil { - return nil, err - } - ids, _ := packedInts(proto, 5) - types, _ := packedInts(proto, 6) - weights, _ := packedInts(proto, 8) - if len(ids) == 0 || len(ids) != len(types) || len(ids) != len(weights) { - return nil, errors.New("malformed reward group") - } - var result []WeightedCostume - for i, id := range ids { - switch types[i] { - case 11: - result = append(result, WeightedCostume{ID: id, Weight: weights[i]}) - case 9: - children, err := loadCostumeRewardPool(db, id) - if err != nil { - return nil, err - } - result = append(result, WeightedCostume{Weight: weights[i], Children: children}) - default: - return nil, fmt.Errorf("unsupported costume reward type %d", types[i]) - } - } - return result, nil -} - func validateCostumePool(pool []WeightedCostume, characters map[uint64]CharacterDesign) error { if len(pool) == 0 { return errors.New("gamedata: empty costume pool") @@ -1704,20 +1633,24 @@ func LoadInfiniteGachaForSchedules(root, version string, groups []uint64) (*Infi for rows.Next() { var levelProto []byte if err := rows.Scan(&levelProto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } groups, _ := packedInts(levelProto, 5) levels, _ := packedInts(levelProto, 7) health, healthFound, healthErr := fixed64Double(levelProto, 6) if healthErr != nil { - rows.Close() + _ = rows.Close() return nil, healthErr } if len(groups) == 1 && len(levels) == 1 && levels[0] == 1 && healthFound && health >= 0 && !math.IsNaN(health) && !math.IsInf(health, 0) { levelOneHealthRatio[groups[0]] = health } } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } if err := rows.Close(); err != nil { return nil, err } diff --git a/go/internal/server/gamedata/gacha_cash_test.go b/go/internal/server/gamedata/gacha_cash_test.go index ce182c0..02bed50 100644 --- a/go/internal/server/gamedata/gacha_cash_test.go +++ b/go/internal/server/gamedata/gacha_cash_test.go @@ -11,7 +11,11 @@ func TestCashRewardsUseItemCountAndRejectRandomIndependentRolls(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err := db.Exec("CREATE TABLE RewardGroupTable(id INTEGER,ProtoBuf BLOB)"); err != nil { t.Fatal(err) } diff --git a/go/internal/server/gamedata/gacha_character.go b/go/internal/server/gamedata/gacha_character.go index 4bd2a8a..42f402a 100644 --- a/go/internal/server/gamedata/gacha_character.go +++ b/go/internal/server/gamedata/gacha_character.go @@ -28,7 +28,7 @@ func loadCostumeCharacterFamily(db *sql.DB, costumeID uint64) (uint64, []uint64, if err != nil { return 0, nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() var result uint64 var family []uint64 for rows.Next() { diff --git a/go/internal/server/gamedata/gacha_character_test.go b/go/internal/server/gamedata/gacha_character_test.go index 3919df3..246c374 100644 --- a/go/internal/server/gamedata/gacha_character_test.go +++ b/go/internal/server/gamedata/gacha_character_test.go @@ -11,14 +11,22 @@ func TestCostumeCharacterRelationshipDoesNotUseNumericPrefix(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err := db.Exec("CREATE TABLE CostumeTable(id INTEGER,ProtoBuf BLOB);CREATE TABLE CharTable(id INTEGER,uniqueCharId INTEGER,ProtoBuf BLOB)"); err != nil { t.Fatal(err) } - db.Exec("INSERT INTO CostumeTable VALUES (?,?)", 76543, wire.AppendVarint(nil, 27, 88)) + if _, err := db.Exec("INSERT INTO CostumeTable VALUES (?,?)", 76543, wire.AppendVarint(nil, 27, 88)); err != nil { + t.Fatal(err) + } for _, c := range []struct{ id, growth, temp uint64 }{{999, 1, 0}, {1000, 2, 0}, {222, 1, 4}} { raw := wire.AppendVarint(wire.AppendVarint(nil, 10, c.growth), 21, c.temp) - db.Exec("INSERT INTO CharTable VALUES (?,?,?)", c.id, 88, raw) + if _, err := db.Exec("INSERT INTO CharTable VALUES (?,?,?)", c.id, 88, raw); err != nil { + t.Fatal(err) + } } got, err := loadCostumeBaseCharacterID(db, 76543) if err != nil || got != 999 { @@ -35,7 +43,9 @@ func TestCostumeCharacterRelationshipDoesNotUseNumericPrefix(t *testing.T) { if !seen[999] || !seen[1000] || seen[222] { t.Fatalf("promotion family=%v", family) } - db.Exec("INSERT INTO CharTable VALUES (?,?,?)", 777, 88, wire.AppendVarint(nil, 10, 1)) + if _, err := db.Exec("INSERT INTO CharTable VALUES (?,?,?)", 777, 88, wire.AppendVarint(nil, 10, 1)); err != nil { + t.Fatal(err) + } if _, err := loadCostumeBaseCharacterID(db, 76543); err == nil { t.Fatal("ambiguous base guessed") } diff --git a/go/internal/server/gamedata/gacha_infinite_metadata.go b/go/internal/server/gamedata/gacha_infinite_metadata.go index ba5587d..056bc5e 100644 --- a/go/internal/server/gamedata/gacha_infinite_metadata.go +++ b/go/internal/server/gamedata/gacha_infinite_metadata.go @@ -10,7 +10,7 @@ func loadInfiniteGachaIdentity(db *sql.DB, selectedGroups []uint64) (groupID, ga if err != nil { return 0, 0, err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var id uint64 var raw []byte @@ -57,7 +57,7 @@ func loadInfiniteCashIdentity(db *sql.DB, rewardID uint64) (groupID, productID, if err != nil { return 0, 0, 0, err } - defer rows.Close() + defer func() { _ = rows.Close() }() var products [][]byte for rows.Next() { var raw []byte diff --git a/go/internal/server/gamedata/gacha_probability_test.go b/go/internal/server/gamedata/gacha_probability_test.go index 88297e5..e566bc8 100644 --- a/go/internal/server/gamedata/gacha_probability_test.go +++ b/go/internal/server/gamedata/gacha_probability_test.go @@ -71,7 +71,7 @@ func TestTenPullGuaranteesGradeFourWhenAllNormalRollsAreGradeThree(t *testing.T) if err != nil { t.Fatal(err) } - for i := 0; i < 9; i++ { + for i := range 9 { if roll[i] != 3001 { t.Fatalf("slot %d=%d want grade-3", i, roll[i]) } diff --git a/go/internal/server/gamedata/growth.go b/go/internal/server/gamedata/growth.go index 4b05dc5..1d27424 100644 --- a/go/internal/server/gamedata/growth.go +++ b/go/internal/server/gamedata/growth.go @@ -68,7 +68,7 @@ func CharacterGrowth(root, version string, charID int, level, exp uint64, materi if err != nil { return 0, 0, nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var proto []byte if err := rows.Scan(&proto); err != nil { @@ -124,7 +124,7 @@ func refundGrowthResources(db *sql.DB, overflow uint64) ([]GrowthMaterial, error if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() type unit struct{ id, value uint64 } var units []unit for rows.Next() { diff --git a/go/internal/server/gamedata/hunting.go b/go/internal/server/gamedata/hunting.go index 97f65e4..59ecf1b 100644 --- a/go/internal/server/gamedata/hunting.go +++ b/go/internal/server/gamedata/hunting.go @@ -48,34 +48,34 @@ func LoadHuntingPack(root, version string, pack int) (*HuntingPack, error) { for rows.Next() { var raw []byte if err := rows.Scan(&raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } g := HuntingGround{} for field, target := range map[int]*uint64{1: &g.BossID, 2: &g.Difficulty, 3: &g.ID, 4: &g.MapID} { v, e := optionalScalar(raw, field) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } *target = v } g.Monsters, err = packedInts(raw, 5) if err != nil { - rows.Close() + _ = rows.Close() return nil, err } if g.ID == 0 || g.MapID == 0 || g.BossID == 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid hunting ground") } d.Grounds = append(d.Grounds, g) } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } - rows.Close() + _ = rows.Close() for _, g := range d.Grounds { ids := append(append([]uint64(nil), g.Monsters...), g.BossID) for _, id := range ids { @@ -112,7 +112,7 @@ func LoadHuntingPack(root, version string, pack int) (*HuntingPack, error) { return nil, fmt.Errorf("gamedata: hunting reward arrays mismatch") } for _, reward := range r { - m.Rewards[deck] = append(m.Rewards[deck], BattleReward{reward.Type, reward.ID, reward.Count}) + m.Rewards[deck] = append(m.Rewards[deck], BattleReward{reward.Type, reward.ID, reward.Count}) //nolint:staticcheck // S1016 } } d.Monsters[id] = m diff --git a/go/internal/server/gamedata/immortal.go b/go/internal/server/gamedata/immortal.go index 02cf887..9eb8b05 100644 --- a/go/internal/server/gamedata/immortal.go +++ b/go/internal/server/gamedata/immortal.go @@ -26,21 +26,21 @@ func LoadImmortalDesign(root, version string) (*ImmortalDesign, error) { var id uint64 var raw []byte if err := rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } group, err := optionalScalar(raw, 18) if err != nil { - rows.Close() + _ = rows.Close() return nil, err } talents[id] = group } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } - rows.Close() + _ = rows.Close() rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable") if err != nil { return nil, err @@ -49,12 +49,12 @@ func LoadImmortalDesign(root, version string) (*ImmortalDesign, error) { var id uint64 var raw []byte if err := rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } talent, err := optionalScalar(raw, 18) if err != nil { - rows.Close() + _ = rows.Close() return nil, err } if group := talents[talent]; group != 0 { @@ -62,15 +62,15 @@ func LoadImmortalDesign(root, version string) (*ImmortalDesign, error) { } } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } - rows.Close() + _ = rows.Close() rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM TalentSkillTable") if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var group, level uint64 var raw []byte diff --git a/go/internal/server/gamedata/inn.go b/go/internal/server/gamedata/inn.go index 7769385..bd7fb51 100644 --- a/go/internal/server/gamedata/inn.go +++ b/go/internal/server/gamedata/inn.go @@ -39,12 +39,12 @@ func loadInns(db, shared *sql.DB) ([]InnRule, error) { var id uint64 var raw []byte if err = rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } interactions, e := packedInts(raw, 9) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } if !slices.Contains(interactions, uint64(2)) { @@ -52,19 +52,19 @@ func loadInns(db, shared *sql.DB) ([]InnRule, error) { } mapID, e := optionalScalar(raw, 14) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } recovery, e := optionalScalar(raw, 21) if e != nil || recovery == 0 || mapID == 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid motel NPC %d", id) } rules = append(rules, InnRule{NPCID: id, MapID: mapID}) recoveryIDs = append(recoveryIDs, recovery) } err = rows.Err() - rows.Close() + _ = rows.Close() if err != nil { return nil, err } diff --git a/go/internal/server/gamedata/inn_test.go b/go/internal/server/gamedata/inn_test.go index a61e245..f458324 100644 --- a/go/internal/server/gamedata/inn_test.go +++ b/go/internal/server/gamedata/inn_test.go @@ -11,12 +11,20 @@ func TestInnLoaderJoinsNPCRecoveryMapAndReputation(t *testing.T) { if err != nil { t.Fatal(err) } - defer pack.Close() + defer func() { + if err := pack.Close(); err != nil { + t.Error(err) + } + }() shared, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatal(err) } - defer shared.Close() + defer func() { + if err := shared.Close(); err != nil { + t.Error(err) + } + }() for _, table := range []string{"FieldNpcTable", "ReputationGroupTable"} { if _, err = pack.Exec("CREATE TABLE " + table + " (id INTEGER, ProtoBuf BLOB)"); err != nil { t.Fatal(err) diff --git a/go/internal/server/gamedata/inventory_slots_test.go b/go/internal/server/gamedata/inventory_slots_test.go index 9a721d3..8c898ea 100644 --- a/go/internal/server/gamedata/inventory_slots_test.go +++ b/go/internal/server/gamedata/inventory_slots_test.go @@ -13,7 +13,11 @@ func TestLoadInventorySlotDesignUsesGameDefaultFields(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err := db.Exec("CREATE TABLE GameDefaultTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)"); err != nil { t.Fatal(err) } diff --git a/go/internal/server/gamedata/item_craft.go b/go/internal/server/gamedata/item_craft.go index 73a5e81..9c7d897 100644 --- a/go/internal/server/gamedata/item_craft.go +++ b/go/internal/server/gamedata/item_craft.go @@ -28,12 +28,12 @@ func loadItemCraftDesign(db *sql.DB) (*ItemCraftDesign, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var id uint64 var raw []byte if err = rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } r := ItemCraftRecipe{ID: id, Class: 8} @@ -52,14 +52,14 @@ func loadItemCraftDesign(db *sql.DB) (*ItemCraftDesign, error) { for f, p := range map[int]*uint64{countField: &r.Result.Count, idField: &r.Result.ID, levelField: &r.TalentLevel} { *p, err = optionalScalar(raw, f) if err != nil { - rows.Close() + _ = rows.Close() return nil, err } } if typeField != 0 { r.Result.Type, err = optionalScalar(raw, typeField) if err != nil { - rows.Close() + _ = rows.Close() return nil, err } } @@ -90,7 +90,7 @@ func loadItemCraftDesign(db *sql.DB) (*ItemCraftDesign, error) { } } err = rows.Err() - rows.Close() + _ = rows.Close() if err != nil { return nil, err } diff --git a/go/internal/server/gamedata/legacy_probability_test.go b/go/internal/server/gamedata/legacy_probability_test.go index 81ea881..a210ddc 100644 --- a/go/internal/server/gamedata/legacy_probability_test.go +++ b/go/internal/server/gamedata/legacy_probability_test.go @@ -2,32 +2,6 @@ package gamedata import "fmt" -func validateRegularCostumeRates(pool []WeightedCostume) error { - if len(pool) != 3 { - return fmt.Errorf("expected three rarity branches, got %d", len(pool)) - } - want := [...]uint64{officialFiveStarRate, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate} - for i, item := range pool { - if item.Weight != want[i] { - return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i]) - } - } - return nil -} - -func validateFixedPickupRemainderRates(pool []WeightedCostume) error { - if len(pool) != 3 { - return fmt.Errorf("expected three rarity branches, got %d", len(pool)) - } - want := [...]uint64{officialFiveStarRate, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate} - for i, item := range pool { - if item.Weight != want[i] { - return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i]) - } - } - return nil -} - // validateOfficialPickupRates prevents a GameData/schema regression from // silently changing the published costume pickup rates. The two five-star // branches are pickup 1.5% plus the ordinary five-star pool 1.5%. diff --git a/go/internal/server/gamedata/limited_costume.go b/go/internal/server/gamedata/limited_costume.go index be030b9..c7e424d 100644 --- a/go/internal/server/gamedata/limited_costume.go +++ b/go/internal/server/gamedata/limited_costume.go @@ -25,7 +25,7 @@ func LoadLimitedCostumes(root, version string) (*LimitedCostumeCatalog, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() catalog := &LimitedCostumeCatalog{characters: map[uint64]CharacterDesign{}} for rows.Next() { var costumeID uint64 diff --git a/go/internal/server/gamedata/mini_content.go b/go/internal/server/gamedata/mini_content.go index a826377..173d345 100644 --- a/go/internal/server/gamedata/mini_content.go +++ b/go/internal/server/gamedata/mini_content.go @@ -26,19 +26,19 @@ func LoadMiniContentDesign(root, version string) (*MiniContentDesign, error) { for rows.Next() { var raw []byte if err = rows.Scan(&raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } id, e := optionalScalar(raw, 2) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } var r Reward for field, target := range map[int]*uint64{10: &r.Type, 9: &r.ID, 8: &r.Count} { v, e := optionalScalar(raw, field) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } *target = v @@ -46,7 +46,7 @@ func LoadMiniContentDesign(root, version string) (*MiniContentDesign, error) { d.Stories[id] = r } err = rows.Err() - rows.Close() + _ = rows.Close() if err != nil { return nil, err } @@ -57,23 +57,23 @@ func LoadMiniContentDesign(root, version string) (*MiniContentDesign, error) { for rows.Next() { var raw []byte if err = rows.Scan(&raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } id, e := optionalScalar(raw, 1) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } group, e := optionalScalar(raw, 2) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } d.Groups[group] = append(d.Groups[group], id) } err = rows.Err() - rows.Close() + _ = rows.Close() if err == nil { for group, ids := range d.Groups { for _, id := range ids { diff --git a/go/internal/server/gamedata/missions.go b/go/internal/server/gamedata/missions.go index 3128394..802a8e4 100644 --- a/go/internal/server/gamedata/missions.go +++ b/go/internal/server/gamedata/missions.go @@ -87,7 +87,7 @@ func loadMissionRows(db *sql.DB, design *MissionDesign) error { if err != nil { return fmt.Errorf("gamedata: query MissionTable: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var proto []byte if err := rows.Scan(&proto); err != nil { @@ -157,7 +157,7 @@ func loadSectionRows(db *sql.DB, design *MissionDesign) error { if err != nil { return fmt.Errorf("gamedata: query MissionSectionRewardTable: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var proto []byte if err := rows.Scan(&proto); err != nil { @@ -196,7 +196,7 @@ func loadAchievementRows(db *sql.DB, design *MissionDesign) error { if err != nil { return fmt.Errorf("gamedata: query AchievementTable: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var proto []byte if err := rows.Scan(&proto); err != nil { diff --git a/go/internal/server/gamedata/monster_hunt.go b/go/internal/server/gamedata/monster_hunt.go index 84a4770..55ff974 100644 --- a/go/internal/server/gamedata/monster_hunt.go +++ b/go/internal/server/gamedata/monster_hunt.go @@ -72,12 +72,12 @@ func loadMonsterHunt(db *sql.DB, id uint64) (*MonsterHunt, error) { for rows.Next() { var part []byte if err = rows.Scan(&part); err != nil { - rows.Close() + _ = rows.Close() return nil, err } v, e := optionalScalar(part, 7) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } if body == 0 && v > 0 { @@ -85,7 +85,7 @@ func loadMonsterHunt(db *sql.DB, id uint64) (*MonsterHunt, error) { } } err = rows.Err() - rows.Close() + _ = rows.Close() if err != nil { return nil, err } @@ -135,17 +135,17 @@ func loadMonsterHunt(db *sql.DB, id uint64) (*MonsterHunt, error) { var lv uint64 var r []byte if err = rows.Scan(&lv, &r); err != nil { - rows.Close() + _ = rows.Close() return nil, err } clear, e := monsterHuntRewardArrays(r, 8, 7, 6) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } daily, e := monsterHuntRewardArrays(r, 3, 2, 1) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } d.Rewards[lv] = MonsterHuntRewards{clear, daily} @@ -154,7 +154,7 @@ func loadMonsterHunt(db *sql.DB, id uint64) (*MonsterHunt, error) { } } err = rows.Err() - rows.Close() + _ = rows.Close() if err != nil { return nil, err } @@ -167,28 +167,28 @@ func loadMonsterHunt(db *sql.DB, id uint64) (*MonsterHunt, error) { var r MonsterHuntRankReward var p []byte if err = rows.Scan(&g, &r.ID, &p); err != nil { - rows.Close() + _ = rows.Close() return nil, err } r.Type, err = optionalScalar(p, 6) if err != nil { - rows.Close() + _ = rows.Close() return nil, err } r.Ranking, _, err = fixed64Double(p, 5) if err != nil { - rows.Close() + _ = rows.Close() return nil, err } r.Rewards, err = monsterHuntRewardArrays(p, 9, 8, 7) if err != nil { - rows.Close() + _ = rows.Close() return nil, err } d.Ranks[g] = append(d.Ranks[g], r) } err = rows.Err() - rows.Close() + _ = rows.Close() if err != nil { return nil, err } diff --git a/go/internal/server/gamedata/npc_reputation.go b/go/internal/server/gamedata/npc_reputation.go index d9d849a..fcc1f8e 100644 --- a/go/internal/server/gamedata/npc_reputation.go +++ b/go/internal/server/gamedata/npc_reputation.go @@ -23,25 +23,25 @@ func LoadNPCReputation(root, version string, pack int) (NPCReputationDesign, err var id uint64 var raw []byte if err = rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return d, err } r := NPCReputationRule{ID: id} for f, p := range map[int]*uint64{1: &r.DownHours, 2: &r.GoodInn, 3: &r.GoodPrice} { *p, err = optionalScalar(raw, f) if err != nil { - rows.Close() + _ = rows.Close() return d, err } } if id == 0 || r.GoodInn > 100 || r.GoodPrice > 100 || r.DownHours > 596523 { - rows.Close() + _ = rows.Close() return d, fmt.Errorf("gamedata: invalid reputation rule") } d.Groups[id] = r } err = rows.Err() - rows.Close() + _ = rows.Close() if err != nil { return d, err } @@ -54,7 +54,7 @@ func LoadNPCReputation(root, version string, pack int) (NPCReputationDesign, err if err != nil { return d, err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var id int var raw []byte diff --git a/go/internal/server/gamedata/npc_shop.go b/go/internal/server/gamedata/npc_shop.go index e71910f..b5ff7f9 100644 --- a/go/internal/server/gamedata/npc_shop.go +++ b/go/internal/server/gamedata/npc_shop.go @@ -3,7 +3,7 @@ package gamedata import ( "database/sql" "fmt" - "sort" + "slices" ) // NPCShopDesign is the ordinary in-game shop, distinct from CashShopTable. @@ -46,7 +46,7 @@ func LoadNPCShopDesign(root, version string) (NPCShopDesign, error) { for pack := range packs { ids = append(ids, pack) } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) for _, pack := range ids { bindings, e := LoadNPCShopActors(root, version, int(pack)) if e != nil { @@ -74,7 +74,7 @@ func LoadNPCShopActors(root, version string, pack int) (map[uint64][]uint64, err if e != nil { return nil, e } - defer rows.Close() + defer func() { _ = rows.Close() }() out := map[uint64][]uint64{} for rows.Next() { var id uint64 diff --git a/go/internal/server/gamedata/npc_shop_test.go b/go/internal/server/gamedata/npc_shop_test.go index 226b08d..2475be6 100644 --- a/go/internal/server/gamedata/npc_shop_test.go +++ b/go/internal/server/gamedata/npc_shop_test.go @@ -11,7 +11,11 @@ func TestNPCShopCatalogKeepsCompositeProductKeysAndRejectsOrphans(t *testing.T) if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() for _, query := range []string{"CREATE TABLE ShopTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE ProductTable(id INTEGER,groupId INTEGER,ProtoBuf BLOB)", "CREATE TABLE SellItemTable(id INTEGER,ProtoBuf BLOB)"} { if _, err = db.Exec(query); err != nil { t.Fatal(err) @@ -47,7 +51,11 @@ func TestSellCatalogUsesTypesAndRejectsAmbiguousDefinitions(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() for _, q := range []string{"CREATE TABLE ShopTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE ProductTable(id INTEGER,groupId INTEGER,ProtoBuf BLOB)", "CREATE TABLE SellItemTable(id INTEGER,ProtoBuf BLOB)"} { if _, err = db.Exec(q); err != nil { t.Fatal(err) diff --git a/go/internal/server/gamedata/overwhelm.go b/go/internal/server/gamedata/overwhelm.go index c7e97ae..53014f6 100644 --- a/go/internal/server/gamedata/overwhelm.go +++ b/go/internal/server/gamedata/overwhelm.go @@ -19,7 +19,7 @@ func LoadSkyWayOverwhelm(root, version string) ([]SkyWayOverwhelmRule, error) { if e != nil { return nil, e } - defer rows.Close() + defer func() { _ = rows.Close() }() var out []SkyWayOverwhelmRule for rows.Next() { var b []byte @@ -87,7 +87,7 @@ func LoadOverwhelmQuest(root, version string, pack, quest int) (OverwhelmQuestRu if e != nil { return r, e } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var id uint64 var raw []byte diff --git a/go/internal/server/gamedata/pack_detail.go b/go/internal/server/gamedata/pack_detail.go index 8209c13..dfca2cc 100644 --- a/go/internal/server/gamedata/pack_detail.go +++ b/go/internal/server/gamedata/pack_detail.go @@ -26,7 +26,7 @@ func loadPackDetailDesign(db *sql.DB) (PackDetailDesign, error) { if err != nil { return PackDetailDesign{}, err } - defer rows.Close() + defer func() { _ = rows.Close() }() var design PackDetailDesign for rows.Next() { var id int diff --git a/go/internal/server/gamedata/pack_detail_test.go b/go/internal/server/gamedata/pack_detail_test.go index a149ea9..ae068e8 100644 --- a/go/internal/server/gamedata/pack_detail_test.go +++ b/go/internal/server/gamedata/pack_detail_test.go @@ -11,7 +11,11 @@ func TestPackDetailDesignOnlyIncludesClientRegenRewardCategory(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err := db.Exec("CREATE TABLE FieldMonsterTable(id INTEGER,ProtoBuf BLOB)"); err != nil { t.Fatal(err) } diff --git a/go/internal/server/gamedata/pack_jam.go b/go/internal/server/gamedata/pack_jam.go index 31b961e..2462cd9 100644 --- a/go/internal/server/gamedata/pack_jam.go +++ b/go/internal/server/gamedata/pack_jam.go @@ -25,7 +25,7 @@ func loadPackJamDesign(db *sql.DB) (*PackJamDesign, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() var design *PackJamDesign for rows.Next() { var id int diff --git a/go/internal/server/gamedata/pack_jam_test.go b/go/internal/server/gamedata/pack_jam_test.go index 76e5e93..5f89225 100644 --- a/go/internal/server/gamedata/pack_jam_test.go +++ b/go/internal/server/gamedata/pack_jam_test.go @@ -11,7 +11,11 @@ func TestPackJamDesignLoadsDefaultsAndRejectsUnsupportedRewards(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err := db.Exec("CREATE TABLE PackJamEventTable(id INTEGER,ProtoBuf BLOB)"); err != nil { t.Fatal(err) } diff --git a/go/internal/server/gamedata/pack_recovery.go b/go/internal/server/gamedata/pack_recovery.go index 0235e9d..57305cc 100644 --- a/go/internal/server/gamedata/pack_recovery.go +++ b/go/internal/server/gamedata/pack_recovery.go @@ -14,7 +14,7 @@ func LoadPackRecoveryPolicy(root, version string) (*PackRecoveryPolicy, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() p := &PackRecoveryPolicy{Types: map[int]uint64{}} for rows.Next() { var id int diff --git a/go/internal/server/gamedata/pack_summary.go b/go/internal/server/gamedata/pack_summary.go index 13a6c3f..3a9b472 100644 --- a/go/internal/server/gamedata/pack_summary.go +++ b/go/internal/server/gamedata/pack_summary.go @@ -21,7 +21,7 @@ func loadPackSummaryTargets(db *sql.DB) (map[int]bool, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() result := map[int]bool{} for rows.Next() { var id int diff --git a/go/internal/server/gamedata/pack_summary_test.go b/go/internal/server/gamedata/pack_summary_test.go index 0fb5aaa..5827a03 100644 --- a/go/internal/server/gamedata/pack_summary_test.go +++ b/go/internal/server/gamedata/pack_summary_test.go @@ -11,7 +11,11 @@ func TestPackSummaryTargetsMatchClientCategories(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err := db.Exec("CREATE TABLE PackTable(id INTEGER,ProtoBuf BLOB)"); err != nil { t.Fatal(err) } diff --git a/go/internal/server/gamedata/pictorial.go b/go/internal/server/gamedata/pictorial.go index 3b42500..9cd2b03 100644 --- a/go/internal/server/gamedata/pictorial.go +++ b/go/internal/server/gamedata/pictorial.go @@ -273,7 +273,7 @@ func loadProtoRows(db *sql.DB, table string, consume func([]byte) error) error { if err != nil { return fmt.Errorf("gamedata: query %s: %w", table, err) } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var proto []byte if err := rows.Scan(&proto); err != nil { diff --git a/go/internal/server/gamedata/preset.go b/go/internal/server/gamedata/preset.go index 6b2397a..8ed835e 100644 --- a/go/internal/server/gamedata/preset.go +++ b/go/internal/server/gamedata/preset.go @@ -52,7 +52,7 @@ func loadPresetDesign(db *sql.DB) (*PresetDesign, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() d.Icons = map[uint64]bool{} for rows.Next() { var id uint64 diff --git a/go/internal/server/gamedata/preset_test.go b/go/internal/server/gamedata/preset_test.go index 1b58f3f..4b5d54c 100644 --- a/go/internal/server/gamedata/preset_test.go +++ b/go/internal/server/gamedata/preset_test.go @@ -11,7 +11,11 @@ func TestPresetDesignReadsChangedLimitsAndPrice(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err = db.Exec("CREATE TABLE GameDefaultTable(id INTEGER,ProtoBuf BLOB)"); err != nil { t.Fatal(err) } diff --git a/go/internal/server/gamedata/promotion.go b/go/internal/server/gamedata/promotion.go index a1360fc..3d94b54 100644 --- a/go/internal/server/gamedata/promotion.go +++ b/go/internal/server/gamedata/promotion.go @@ -3,6 +3,7 @@ package gamedata import ( "database/sql" "fmt" + "maps" ) // PromotionCost is one exact CharGrowthTable.ClassupItem{Type,Id,Count} row. @@ -111,9 +112,7 @@ func characterGrowthPromotions(db *sql.DB, charID int, level, exp uint64, submit } remaining := make(map[[2]uint64]uint64, len(requested)) - for key, count := range requested { - remaining[key] = count - } + maps.Copy(remaining, requested) for _, cost := range cumulative { key := [2]uint64{cost.Type, cost.ID} if remaining[key] < cost.Count { diff --git a/go/internal/server/gamedata/promotion_test.go b/go/internal/server/gamedata/promotion_test.go index 3c5c800..4ff8a52 100644 --- a/go/internal/server/gamedata/promotion_test.go +++ b/go/internal/server/gamedata/promotion_test.go @@ -49,7 +49,11 @@ func TestCharacterPromotionUsesCharAndGrowthTables(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() for _, schema := range []string{ "CREATE TABLE CharTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)", "CREATE TABLE CharGrowthTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)", @@ -93,7 +97,11 @@ func TestCharacterGrowthPromotionsCrossesTwoStagesWithCumulativeCosts(t *testing if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() for _, schema := range []string{ "CREATE TABLE CharTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)", "CREATE TABLE CharGrowthTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)", diff --git a/go/internal/server/gamedata/quest_collections_test.go b/go/internal/server/gamedata/quest_collections_test.go index 80a7f96..b4391a3 100644 --- a/go/internal/server/gamedata/quest_collections_test.go +++ b/go/internal/server/gamedata/quest_collections_test.go @@ -12,7 +12,11 @@ func TestQuestCollectionsFollowChangedPackQuestAndItemIDs(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() db.SetMaxOpenConns(1) for _, sqlText := range []string{"CREATE TABLE QuestTable77(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE CollectionTable(id INTEGER,ProtoBuf BLOB)"} { if _, err := db.Exec(sqlText); err != nil { diff --git a/go/internal/server/gamedata/quest_difficulty.go b/go/internal/server/gamedata/quest_difficulty.go index c82c763..8a7d1f9 100644 --- a/go/internal/server/gamedata/quest_difficulty.go +++ b/go/internal/server/gamedata/quest_difficulty.go @@ -20,7 +20,7 @@ func loadQuestDifficultiesDB(db *sql.DB) (map[int]map[int]bool, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() result := map[int]map[int]bool{} for rows.Next() { var pack, level int diff --git a/go/internal/server/gamedata/quest_formation.go b/go/internal/server/gamedata/quest_formation.go index 4b4f3fa..28d0753 100644 --- a/go/internal/server/gamedata/quest_formation.go +++ b/go/internal/server/gamedata/quest_formation.go @@ -62,8 +62,7 @@ func loadQuestFormationsDB(db *sql.DB, packID int) (map[int]QuestFormation, erro if err != nil { return nil, fmt.Errorf("gamedata: query QuestTable%d formations: %w", packID, err) } - defer rows.Close() - + defer func() { _ = rows.Close() }() formations := make(map[int]QuestFormation) charGroups := make(map[uint64][]QuestCharacterDesign) storyGroups := make(map[uint64][]QuestCostumeDesign) @@ -140,7 +139,7 @@ func loadCharacterGroup(db *sql.DB, groupID uint64) ([]QuestCharacterDesign, err if err != nil { return nil, fmt.Errorf("query character group %d: %w", groupID, err) } - defer rows.Close() + defer func() { _ = rows.Close() }() var result []QuestCharacterDesign for rows.Next() { var rowID int @@ -180,7 +179,7 @@ func loadStoryCostumeGroup(db *sql.DB, groupID uint64) ([]QuestCostumeDesign, er if err != nil { return nil, fmt.Errorf("query story costume group %d: %w", groupID, err) } - defer rows.Close() + defer func() { _ = rows.Close() }() var result []QuestCostumeDesign type storyRow struct { id int diff --git a/go/internal/server/gamedata/quest_formation_test.go b/go/internal/server/gamedata/quest_formation_test.go index 594b006..3ab6265 100644 --- a/go/internal/server/gamedata/quest_formation_test.go +++ b/go/internal/server/gamedata/quest_formation_test.go @@ -16,7 +16,11 @@ func TestLoadQuestFormationsDB(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() // Match production's single-connection database lease so nested formation // and costume lookups require source rows to be released first. db.SetMaxOpenConns(1) diff --git a/go/internal/server/gamedata/quests.go b/go/internal/server/gamedata/quests.go index 1a9eca4..52d11d9 100644 --- a/go/internal/server/gamedata/quests.go +++ b/go/internal/server/gamedata/quests.go @@ -40,7 +40,7 @@ func loadQuestDesignDB(db *sql.DB, packID int) (map[int]QuestDesign, error) { if err != nil { return nil, fmt.Errorf("gamedata: query QuestTable%d: %w", packID, err) } - defer rows.Close() + defer func() { _ = rows.Close() }() type questRow struct { id int proto []byte @@ -99,7 +99,7 @@ func loadQuestDesignDB(db *sql.DB, packID int) (map[int]QuestDesign, error) { *dst = int(values[0]) } } - for slot := 0; slot < len(entry.Rewards); slot++ { + for slot := range len(entry.Rewards) { types, err := packedInts(proto, 56+slot) if err != nil { return nil, fmt.Errorf("gamedata: quest %d reward slot %d type: %w", id, slot, err) diff --git a/go/internal/server/gamedata/random_box.go b/go/internal/server/gamedata/random_box.go index adb3024..3a39df2 100644 --- a/go/internal/server/gamedata/random_box.go +++ b/go/internal/server/gamedata/random_box.go @@ -36,27 +36,27 @@ func LoadRandomBoxDesign(root, version string) (*RandomBoxDesign, error) { var id uint64 var raw []byte if err := rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } ids, err := packedInts(raw, 5) if err != nil { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: random box reward group %d item IDs: %w", id, err) } types, err := packedInts(raw, 6) if err != nil { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: random box reward group %d item types: %w", id, err) } counts, err := packedInts(raw, 4) if err != nil { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: random box reward group %d item counts: %w", id, err) } ratios, err := packedInts(raw, 8) if err != nil { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: random box reward group %d ratios: %w", id, err) } // A single reward is deterministic. Ratios are presentation/probability @@ -66,6 +66,10 @@ func LoadRandomBoxDesign(root, version string) (*RandomBoxDesign, error) { } groups[id] = []BattleReward{{Type: types[0], ID: ids[0], Count: counts[0]}} } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } if err := rows.Close(); err != nil { return nil, err } @@ -79,23 +83,27 @@ func LoadRandomBoxDesign(root, version string) (*RandomBoxDesign, error) { var rowID uint64 var raw []byte if err := rows.Scan(&rowID, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } ids, err := packedInts(raw, 4) if err != nil || len(ids) != 1 || ids[0] != rowID { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: malformed random box %d", rowID) } groupIDs, err := packedInts(raw, 9) if err != nil || len(groupIDs) != 1 || groupIDs[0] == 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: random box %d reward group: %w", rowID, err) } if rewards, ok := groups[groupIDs[0]]; ok { design.rewards[rowID] = append([]BattleReward(nil), rewards...) } } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } if err := rows.Close(); err != nil { return nil, err } diff --git a/go/internal/server/gamedata/recruit_npc.go b/go/internal/server/gamedata/recruit_npc.go index 1659a05..fdedf48 100644 --- a/go/internal/server/gamedata/recruit_npc.go +++ b/go/internal/server/gamedata/recruit_npc.go @@ -98,7 +98,7 @@ func loadRecruitNPCQuestTypes(db *sql.DB, packID int, npc *RecruitNPC) error { if err != nil { return err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var quest uint64 var raw []byte diff --git a/go/internal/server/gamedata/recruit_npc_test.go b/go/internal/server/gamedata/recruit_npc_test.go index f84c2e7..6fe3fcc 100644 --- a/go/internal/server/gamedata/recruit_npc_test.go +++ b/go/internal/server/gamedata/recruit_npc_test.go @@ -11,7 +11,11 @@ func TestRecruitNPCResolvesScoutParallelInteractionWithZeroPackField(t *testing. if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err = db.Exec("CREATE TABLE FieldNpcTable(id INTEGER PRIMARY KEY,ProtoBuf BLOB)"); err != nil { t.Fatal(err) } diff --git a/go/internal/server/gamedata/recruit_test.go b/go/internal/server/gamedata/recruit_test.go index 0797dd7..526c0f7 100644 --- a/go/internal/server/gamedata/recruit_test.go +++ b/go/internal/server/gamedata/recruit_test.go @@ -3,6 +3,7 @@ package gamedata import ( "bd2server/internal/server/wire" "database/sql" + "maps" "strings" "testing" ) @@ -18,9 +19,7 @@ func TestRecruitSpecialDefaultsUseProtoZeroAndValidateConfiguration(t *testing.T } for _, field := range []int{1, 2, 4, 6, 7} { copyFields := map[int][]uint64{} - for key, values := range fields { - copyFields[key] = values - } + maps.Copy(copyFields, fields) delete(copyFields, field) if e := decodeRecruitSpecialDefaults(friendshipTestProto(copyFields), &RecruitDesign{}); e == nil { t.Fatalf("missing configuration field %d accepted", field) @@ -50,7 +49,11 @@ func TestRecruitLoaderRejectsInvalidProtocolRows(t *testing.T) { if e != nil { t.Fatal(e) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, e = db.Exec("CREATE TABLE MercenaryScoutTable(id INTEGER,ProtoBuf BLOB)"); e != nil { t.Fatal(e) } diff --git a/go/internal/server/gamedata/reward_costume.go b/go/internal/server/gamedata/reward_costume.go index 0f5812e..c466901 100644 --- a/go/internal/server/gamedata/reward_costume.go +++ b/go/internal/server/gamedata/reward_costume.go @@ -17,17 +17,17 @@ func LoadRewardCostumeCatalog(root, version string) (*RegularGachaCatalog, error var unique uint64 var raw []byte if e = chars.Scan(&unique, &raw); e != nil { - chars.Close() + _ = chars.Close() return nil, e } growth, e := optionalScalar(raw, 10) if e != nil { - chars.Close() + _ = chars.Close() return nil, e } temporary, e := optionalScalar(raw, 21) if e != nil { - chars.Close() + _ = chars.Close() return nil, e } if growth == 1 && temporary == 0 { @@ -35,10 +35,10 @@ func LoadRewardCostumeCatalog(root, version string) (*RegularGachaCatalog, error } } if e = chars.Err(); e != nil { - chars.Close() + _ = chars.Close() return nil, e } - chars.Close() + _ = chars.Close() rows, err := db.Query("SELECT id,useUniqueCharId FROM CostumeTable ORDER BY id") if err != nil { return nil, err @@ -47,7 +47,7 @@ func LoadRewardCostumeCatalog(root, version string) (*RegularGachaCatalog, error for rows.Next() { var id, unique uint64 if err = rows.Scan(&id, &unique); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if eligible[unique] == 1 { @@ -55,10 +55,10 @@ func LoadRewardCostumeCatalog(root, version string) (*RegularGachaCatalog, error } } if err = rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } - rows.Close() + _ = rows.Close() out := &RegularGachaCatalog{characters: map[uint64]CharacterDesign{}} for _, id := range ids { design, e := loadGachaCharacterDesign(db, id) diff --git a/go/internal/server/gamedata/reward_equipment.go b/go/internal/server/gamedata/reward_equipment.go index e97ed74..d0169c7 100644 --- a/go/internal/server/gamedata/reward_equipment.go +++ b/go/internal/server/gamedata/reward_equipment.go @@ -16,13 +16,13 @@ func LoadRewardEquipmentCatalog(root, version string) (*EquipmentGachaCatalog, e for rows.Next() { var id uint64 if err = rows.Scan(&id); err != nil { - rows.Close() + _ = rows.Close() return nil, err } ids = append(ids, id) } if err = rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if err = rows.Close(); err != nil { diff --git a/go/internal/server/gamedata/reward_graph.go b/go/internal/server/gamedata/reward_graph.go index fcaf0ce..3449306 100644 --- a/go/internal/server/gamedata/reward_graph.go +++ b/go/internal/server/gamedata/reward_graph.go @@ -50,24 +50,24 @@ func loadRewardGraph(db *sql.DB) (*RewardGraph, error) { var id uint64 var raw []byte if e = rows.Scan(&id, &raw); e != nil { - rows.Close() + _ = rows.Close() return nil, e } gid, e := optionalScalar(raw, 9) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } drop, err := optionalScalar(raw, 1) if err != nil { - rows.Close() + _ = rows.Close() return nil, err } // RBD_DIRECT owns the box itself; RBD_OPEN grants its selected contents. // Design also contains special mode 10 boxes; only the verified OPEN // mode is automatically expanded, other modes retain their entity. if drop != 0 && drop != 1 && drop != 10 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: unsupported random box drop type %d", drop) } g.direct[id] = drop != 0 @@ -75,7 +75,7 @@ func loadRewardGraph(db *sql.DB) (*RewardGraph, error) { g.boxes[id] = gid } e = rows.Err() - rows.Close() + _ = rows.Close() if e != nil { return nil, e } @@ -87,13 +87,13 @@ func loadRewardGraph(db *sql.DB) (*RewardGraph, error) { var id uint64 var raw []byte if e = rows.Scan(&id, &raw); e != nil { - rows.Close() + _ = rows.Close() return nil, e } g.groups[id] = append([]byte(nil), raw...) } e = rows.Err() - rows.Close() + _ = rows.Close() return g, e } func (g *RewardGraph) SetSampler(f func(uint64) (uint64, error)) { @@ -208,7 +208,7 @@ func (g *RewardGraph) resolve(rewards []BattleReward, openRoots bool) ([]BattleR if sum == 0 || g.sample == nil { return fmt.Errorf("gamedata: empty weighted reward pool") } - for i := uint64(0); i < count; i++ { + for range count { if budget == 0 { return fmt.Errorf("gamedata: reward graph operation limit") } diff --git a/go/internal/server/gamedata/reward_skin.go b/go/internal/server/gamedata/reward_skin.go index 6fc8730..f86c0dc 100644 --- a/go/internal/server/gamedata/reward_skin.go +++ b/go/internal/server/gamedata/reward_skin.go @@ -2,6 +2,7 @@ package gamedata import ( "fmt" + "slices" "sort" ) @@ -27,7 +28,7 @@ func LoadPrestigeSkinCatalog(root, version string) (*PrestigeSkinCatalog, error) if e != nil { return nil, e } - defer rows.Close() + defer func() { _ = rows.Close() }() catalog := &PrestigeSkinCatalog{Skins: map[uint64]uint64{}} for rows.Next() { var id uint64 @@ -47,7 +48,7 @@ func LoadPrestigeSkinCatalog(root, version string) (*PrestigeSkinCatalog, error) if e := rows.Err(); e != nil { return nil, e } - rows.Close() + _ = rows.Close() rows, e = db.Query("SELECT ProtoBuf FROM SkinSellingTable ORDER BY groupId,id") if e != nil { return nil, e @@ -95,7 +96,7 @@ func (c *PrestigeSkinCatalog) Giftable(cashAvailable func(CashProductKey) bool) result = append(result, design) } } - sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) + slices.Sort(result) return result } diff --git a/go/internal/server/gamedata/stat_design.go b/go/internal/server/gamedata/stat_design.go index df61976..e45ec23 100644 --- a/go/internal/server/gamedata/stat_design.go +++ b/go/internal/server/gamedata/stat_design.go @@ -78,27 +78,27 @@ func loadCharacterStatDesign(db *sql.DB) (*CharacterStatDesign, error) { var id uint64 var proto []byte if err := rows.Scan(&id, &proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } growthIDs, err := packedInts(proto, 1) if err != nil || len(growthIDs) != 1 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: character %d growth id %v: %w", id, growthIDs, err) } health, found, err := fixed64Double(proto, 11) if err != nil || !found { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: character %d health: %w", id, err) } attack, _, err := fixed64Double(proto, 17) if err != nil { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: character %d attack: %w", id, err) } magic, _, err := fixed64Double(proto, 14) if err != nil { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: character %d magic: %w", id, err) } design.characters[id] = characterStatBase{GrowthID: growthIDs[0], Base: BaseStats{Health: health, Attack: attack, Magic: magic}} @@ -118,12 +118,12 @@ func loadCharacterStatDesign(db *sql.DB) (*CharacterStatDesign, error) { var id uint64 var proto []byte if err := rows.Scan(&id, &proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } groups, err := packedInts(proto, 1) if err != nil || len(groups) != 1 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: character growth %d group %v: %w", id, groups, err) } design.growthGroups[id] = groups[0] @@ -143,22 +143,22 @@ func loadCharacterStatDesign(db *sql.DB) (*CharacterStatDesign, error) { var group, level uint64 var proto []byte if err := rows.Scan(&group, &level, &proto); err != nil { - rows.Close() + _ = rows.Close() return nil, err } health, _, err := fixed64Double(proto, 6) if err != nil { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: character level %d/%d health: %w", group, level, err) } attack, _, err := fixed64Double(proto, 12) if err != nil { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: character level %d/%d attack: %w", group, level, err) } magic, _, err := fixed64Double(proto, 10) if err != nil { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: character level %d/%d magic: %w", group, level, err) } design.levelRatios[[2]uint64{group, level}] = BaseStats{Health: health, Attack: attack, Magic: magic} diff --git a/go/internal/server/gamedata/story_catalog.go b/go/internal/server/gamedata/story_catalog.go index cf99ab5..fc34d27 100644 --- a/go/internal/server/gamedata/story_catalog.go +++ b/go/internal/server/gamedata/story_catalog.go @@ -52,7 +52,7 @@ func loadStoryCatalog(db *sql.DB) (*StoryCatalog, error) { var id int var raw []byte if err := rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } read := func(field int) (int, error) { @@ -67,7 +67,7 @@ func loadStoryCatalog(db *sql.DB) (*StoryCatalog, error) { } kind, err := read(55) if err != nil { - rows.Close() + _ = rows.Close() return nil, err } if kind != 0 && kind != 1000 { @@ -75,39 +75,39 @@ func loadStoryCatalog(db *sql.DB) (*StoryCatalog, error) { } protoID, err := read(25) if err != nil || protoID != id || id <= 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid story pack%d identity", id) } pack := StoryPack{ID: id, Type: kind} buyType, err := read(11) if err != nil { - rows.Close() + _ = rows.Close() return nil, err } pack.BuyType = uint64(buyType) buyPrice, err := read(7) if err != nil { - rows.Close() + _ = rows.Close() return nil, err } pack.BuyPrice = uint64(buyPrice) counts, err := packedInts(raw, 8) if err != nil { - rows.Close() + _ = rows.Close() return nil, err } ids, err := packedInts(raw, 9) if err != nil { - rows.Close() + _ = rows.Close() return nil, err } types, err := packedInts(raw, 10) if err != nil { - rows.Close() + _ = rows.Close() return nil, err } if len(counts) != len(ids) || len(ids) != len(types) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid story pack%d buy rewards", id) } for i, count := range counts { @@ -117,27 +117,27 @@ func loadStoryCatalog(db *sql.DB) (*StoryCatalog, error) { continue } if types[i] == 0 || count > math.MaxInt32 || types[i] > math.MaxInt32 || ids[i] > math.MaxInt32 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid story pack%d buy reward", id) } pack.BuyRewards = append(pack.BuyRewards, Reward{Type: types[i], ID: ids[i], Count: count}) } if pack.PriorPackID, err = read(57); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if pack.NextPackID, err = read(45); err != nil { - rows.Close() + _ = rows.Close() return nil, err } maps, err := packedInts(raw, 21) if err != nil { - rows.Close() + _ = rows.Close() return nil, err } for _, mapID := range maps { if mapID == 0 || mapID > math.MaxInt32 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid story pack%d map", id) } pack.MapIDs = append(pack.MapIDs, int(mapID)) @@ -145,10 +145,10 @@ func loadStoryCatalog(db *sql.DB) (*StoryCatalog, error) { d.Packs[id] = pack } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } - rows.Close() + _ = rows.Close() rows, err = db.Query("SELECT id,ProtoBuf FROM ContentOpenTable WHERE groupId=1 ORDER BY id") if err != nil { return nil, err @@ -157,7 +157,7 @@ func loadStoryCatalog(db *sql.DB) (*StoryCatalog, error) { var id int var raw []byte if err := rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } pack, found := d.Packs[id] @@ -169,7 +169,7 @@ func loadStoryCatalog(db *sql.DB) (*StoryCatalog, error) { for field, dst := range map[int]*uint64{1: &group, 2: &protoID, 4: &open.TutorialID, 5: &open.SquadLevel, 6: &open.TicketID} { values, err := packedInts(raw, field) if err != nil || len(values) > 1 || (len(values) == 1 && values[0] > math.MaxInt32) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid story contentopen%d", id) } if len(values) == 1 { @@ -177,17 +177,17 @@ func loadStoryCatalog(db *sql.DB) (*StoryCatalog, error) { } } if group != 1 || protoID != uint64(id) || open.TicketID == 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid story contentopen%d key/ticket", id) } pack.Open = open d.Packs[id] = pack } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } - rows.Close() + _ = rows.Close() for id, pack := range d.Packs { pack.Quests, err = loadQuestDesignDB(db, id) if err != nil { diff --git a/go/internal/server/gamedata/story_catalog_test.go b/go/internal/server/gamedata/story_catalog_test.go index 6237b2e..b5d4b65 100644 --- a/go/internal/server/gamedata/story_catalog_test.go +++ b/go/internal/server/gamedata/story_catalog_test.go @@ -11,7 +11,11 @@ func TestStoryCatalogEnumeratesSeparateChainsAndContentTicketRules(t *testing.T) if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() for _, query := range []string{"CREATE TABLE PackTable(id INTEGER PRIMARY KEY,ProtoBuf BLOB)", "CREATE TABLE ContentOpenTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)", "CREATE TABLE QuestTable1(id INTEGER PRIMARY KEY,ProtoBuf BLOB)", "CREATE TABLE QuestTable7(id INTEGER PRIMARY KEY,ProtoBuf BLOB)", "CREATE TABLE QuestTable8(id INTEGER PRIMARY KEY,ProtoBuf BLOB)"} { if _, err = db.Exec(query); err != nil { t.Fatal(err) diff --git a/go/internal/server/gamedata/talent_dispatch.go b/go/internal/server/gamedata/talent_dispatch.go index 216ecd1..7179c8d 100644 --- a/go/internal/server/gamedata/talent_dispatch.go +++ b/go/internal/server/gamedata/talent_dispatch.go @@ -47,16 +47,16 @@ func LoadTalentDispatchDesign(root, version string) (map[uint64]TalentDispatchDe for rows.Next() { var r row if e = rows.Scan(&r.id, &r.raw); e != nil { - rows.Close() + _ = rows.Close() return nil, e } rs = append(rs, r) } if e = rows.Err(); e != nil { - rows.Close() + _ = rows.Close() return nil, e } - rows.Close() + _ = rows.Close() out := map[uint64]TalentDispatchDesign{} for _, r := range rs { d := TalentDispatchDesign{ID: r.id, Reset: reset} diff --git a/go/internal/server/gamedata/talent_growth.go b/go/internal/server/gamedata/talent_growth.go index 96e1417..81cc7e7 100644 --- a/go/internal/server/gamedata/talent_growth.go +++ b/go/internal/server/gamedata/talent_growth.go @@ -64,27 +64,27 @@ func loadTalentGrowthDesign(db *sql.DB) (*TalentGrowthDesign, error) { var id uint64 var raw []byte if err := rows.Scan(&id, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } groups, err := packedInts(raw, 6) if err != nil { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: talent %d growth group: %w", id, err) } maximum, err := packedInts(raw, 11) if err != nil { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: talent %d maximum level: %w", id, err) } if len(groups) != 1 || groups[0] == 0 || len(maximum) != 1 || maximum[0] == 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: talent %d has invalid growth metadata", id) } talents[id] = CharacterTalent{TalentID: id, GrowthGroup: groups[0], MaxLevel: maximum[0]} } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if err := rows.Close(); err != nil { @@ -103,30 +103,30 @@ func loadTalentGrowthDesign(db *sql.DB) (*TalentGrowthDesign, error) { var characterID uint64 var raw []byte if err := rows.Scan(&characterID, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } talentIDs, err := packedInts(raw, 18) if err != nil { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: character %d talent: %w", characterID, err) } if len(talentIDs) == 0 { continue } if len(talentIDs) != 1 || talentIDs[0] == 0 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: character %d has invalid talent reference", characterID) } talent, found := talents[talentIDs[0]] if !found { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: character %d references unknown talent %d", characterID, talentIDs[0]) } design.Characters[characterID] = talent } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if err := rows.Close(); err != nil { @@ -141,7 +141,7 @@ func loadTalentGrowthDesign(db *sql.DB) (*TalentGrowthDesign, error) { var group, level uint64 var raw []byte if err := rows.Scan(&group, &level, &raw); err != nil { - rows.Close() + _ = rows.Close() return nil, err } counts, countErr := packedInts(raw, 2) @@ -149,11 +149,11 @@ func loadTalentGrowthDesign(db *sql.DB) (*TalentGrowthDesign, error) { types, typeErr := packedInts(raw, 4) needs, needErr := packedInts(raw, 6) if countErr != nil || idErr != nil || typeErr != nil || needErr != nil { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: talent growth %d/%d has invalid fields", group, level) } if group == 0 || level == 0 || len(counts) != len(ids) || len(ids) != len(types) || len(needs) > 1 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: talent growth %d/%d has mismatched fields", group, level) } entry := TalentGrowthLevel{Level: level} @@ -162,20 +162,20 @@ func loadTalentGrowthDesign(db *sql.DB) (*TalentGrowthDesign, error) { } for i := range counts { if types[i] == 0 || counts[i] == 0 || (types[i] == 4 && ids[i] != 0) || (types[i] != 4 && ids[i] == 0) { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: talent growth %d/%d has invalid cost", group, level) } entry.Costs = append(entry.Costs, PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]}) } key := [2]uint64{group, level} if _, exists := design.Levels[key]; exists { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: duplicate talent growth %d/%d", group, level) } design.Levels[key] = entry } if err := rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } if err := rows.Close(); err != nil { diff --git a/go/internal/server/gamedata/talent_growth_test.go b/go/internal/server/gamedata/talent_growth_test.go index 665731a..0dc4be4 100644 --- a/go/internal/server/gamedata/talent_growth_test.go +++ b/go/internal/server/gamedata/talent_growth_test.go @@ -13,7 +13,11 @@ func TestTalentGrowthDesignJoinsCharacterAndUsesCumulativeExperience(t *testing. if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err := db.Exec("CREATE TABLE TalentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE CharTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE TalentGrowthTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))"); err != nil { t.Fatal(err) } @@ -64,7 +68,11 @@ func TestTalentGrowthDesignRejectsMismatchedCostArrays(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() if _, err := db.Exec("CREATE TABLE TalentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE CharTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE TalentGrowthTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))"); err != nil { t.Fatal(err) } diff --git a/go/internal/server/gamedata/talent_use.go b/go/internal/server/gamedata/talent_use.go index 9588fa9..726b571 100644 --- a/go/internal/server/gamedata/talent_use.go +++ b/go/internal/server/gamedata/talent_use.go @@ -91,22 +91,22 @@ func loadTalentUseDesign(db *sql.DB) (*TalentUseDesign, error) { var id uint64 var b []byte if err = rows.Scan(&id, &b); err != nil { - rows.Close() + _ = rows.Close() return nil, err } g, e := optionalScalar(b, 18) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } max, e := optionalScalar(b, 11) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } bans, e := packedInts(b, 1) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } v := TalentUseCharacter{Group: g, MaxLevel: max, BannedPacks: map[int]bool{}} @@ -116,10 +116,10 @@ func loadTalentUseDesign(db *sql.DB) (*TalentUseDesign, error) { talents[id] = v } if err = rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } - rows.Close() + _ = rows.Close() rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable") if err != nil { return nil, err @@ -128,28 +128,28 @@ func loadTalentUseDesign(db *sql.DB) (*TalentUseDesign, error) { var id uint64 var b []byte if err = rows.Scan(&id, &b); err != nil { - rows.Close() + _ = rows.Close() return nil, err } t, e := optionalScalar(b, 18) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } if t > 0 { v, ok := talents[t] if !ok { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: unknown character talent %d", t) } d.Characters[id] = v } } if err = rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } - rows.Close() + _ = rows.Close() rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM TalentSkillTable") if err != nil { return nil, err @@ -158,13 +158,13 @@ func loadTalentUseDesign(db *sql.DB) (*TalentUseDesign, error) { var r TalentUseRule var b []byte if err = rows.Scan(&r.Group, &r.Level, &b); err != nil { - rows.Close() + _ = rows.Close() return nil, err } for f, p := range map[int]*uint64{1: &r.Catalyst, 2: &r.Class, 5: &r.Experience, 8: &r.Reset, 12: &r.Reputation, 13: &r.Target} { *p, err = optionalScalar(b, f) if err != nil || *p > math.MaxInt32 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid talent skill scalar") } } @@ -185,31 +185,31 @@ func loadTalentUseDesign(db *sql.DB) (*TalentUseDesign, error) { return nil }) if err != nil { - rows.Close() + _ = rows.Close() return nil, err } for _, v := range r.Values { if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 || v > math.MaxInt32 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid talent value") } } if r.Group == 0 || r.Level == 0 || r.Class == 0 || r.Class > 20 { - rows.Close() + _ = rows.Close() return nil, fmt.Errorf("gamedata: invalid talent identity") } d.Rules[[2]uint64{r.Group, r.Level}] = r } if err = rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } - rows.Close() + _ = rows.Close() rows, err = db.Query("SELECT id,ProtoBuf FROM TalentRewardTable") if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var id uint64 var b []byte @@ -221,13 +221,13 @@ func loadTalentUseDesign(db *sql.DB) (*TalentUseDesign, error) { return nil, e } for _, r := range rewards { - d.Rewards[id] = append(d.Rewards[id], Reward{r.Type, r.ID, r.Count}) + d.Rewards[id] = append(d.Rewards[id], Reward{r.Type, r.ID, r.Count}) //nolint:staticcheck // S1016 } } if err = rows.Err(); err != nil { return nil, err } - rows.Close() + _ = rows.Close() buffs := map[uint64]uint64{} rows, err = db.Query("SELECT id,ProtoBuf FROM FoodBuffTable") if err != nil { @@ -237,17 +237,17 @@ func loadTalentUseDesign(db *sql.DB) (*TalentUseDesign, error) { var id uint64 var b []byte if err = rows.Scan(&id, &b); err != nil { - rows.Close() + _ = rows.Close() return nil, err } t, e := optionalScalar(b, 2) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } g, e := optionalScalar(b, 3) if e != nil { - rows.Close() + _ = rows.Close() return nil, e } if t == 1 { @@ -255,15 +255,15 @@ func loadTalentUseDesign(db *sql.DB) (*TalentUseDesign, error) { } } if err = rows.Err(); err != nil { - rows.Close() + _ = rows.Close() return nil, err } - rows.Close() + _ = rows.Close() rows, err = db.Query("SELECT id,ProtoBuf FROM FoodTable") if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var id uint64 var b []byte @@ -286,7 +286,7 @@ func loadTalentNPCs(db *sql.DB) (map[uint64]TalentNPC, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var v TalentNPC var b []byte diff --git a/go/internal/server/gamedata/talent_use_test.go b/go/internal/server/gamedata/talent_use_test.go index 13815f0..997bd98 100644 --- a/go/internal/server/gamedata/talent_use_test.go +++ b/go/internal/server/gamedata/talent_use_test.go @@ -13,7 +13,11 @@ func TestTalentUseLoaderJoinsCharacterLevelRewardsAndFood(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() for _, table := range []string{"TalentTable", "CharTable", "TalentRewardTable", "FoodBuffTable", "FoodTable"} { if _, err = db.Exec("CREATE TABLE " + table + "(id INTEGER,ProtoBuf BLOB)"); err != nil { t.Fatal(err) diff --git a/go/internal/server/gamedata/today_quests.go b/go/internal/server/gamedata/today_quests.go index c8f5a2a..a91b742 100644 --- a/go/internal/server/gamedata/today_quests.go +++ b/go/internal/server/gamedata/today_quests.go @@ -43,7 +43,7 @@ func LoadTodayQuests(root, version string) (*TodayQuestCatalog, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var id int var raw []byte diff --git a/go/internal/server/gamedata/validate.go b/go/internal/server/gamedata/validate.go index 50f8e52..7a5cc82 100644 --- a/go/internal/server/gamedata/validate.go +++ b/go/internal/server/gamedata/validate.go @@ -89,13 +89,13 @@ func Ensure(ctx context.Context, client *http.Client, root, version, origin stri return Result{}, false, fmt.Errorf("gamedata: create info staging file: %w", err) } infoStagePath := infoStage.Name() - defer os.Remove(infoStagePath) + defer func() { _ = os.Remove(infoStagePath) }() if _, err := infoStage.Write(infoBytes); err != nil { - infoStage.Close() + _ = infoStage.Close() return Result{}, false, fmt.Errorf("gamedata: write staged info: %w", err) } if err := infoStage.Sync(); err != nil { - infoStage.Close() + _ = infoStage.Close() return Result{}, false, fmt.Errorf("gamedata: sync staged info: %w", err) } if err := infoStage.Close(); err != nil { @@ -179,7 +179,7 @@ func validateArchive(archivePath string, expected int64) (Result, error) { if err != nil { return Result{}, fmt.Errorf("gamedata: open archive: %w", err) } - defer reader.Close() + defer func() { _ = reader.Close() }() if len(reader.File) == 0 { return Result{}, fmt.Errorf("gamedata: archive is empty") } @@ -230,7 +230,7 @@ func downloadSmall(ctx context.Context, client *http.Client, url string, limit i if err != nil { return nil, fmt.Errorf("gamedata: download %s: %w", InfoName, err) } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() if response.StatusCode != http.StatusOK { return nil, fmt.Errorf("gamedata: download %s: HTTP %s", InfoName, response.Status) } @@ -253,7 +253,7 @@ func downloadExact(ctx context.Context, client *http.Client, url string, target if err != nil { return fmt.Errorf("gamedata: download %s: %w", ArchiveName, err) } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() if response.StatusCode != http.StatusOK { return fmt.Errorf("gamedata: download %s: HTTP %s", ArchiveName, response.Status) } diff --git a/go/internal/server/gamedata/waypoint.go b/go/internal/server/gamedata/waypoint.go index 643f127..6877716 100644 --- a/go/internal/server/gamedata/waypoint.go +++ b/go/internal/server/gamedata/waypoint.go @@ -56,7 +56,7 @@ func loadWaypointPoints(db *sql.DB) (map[uint64]Waypoint, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var id uint64 var raw []byte diff --git a/go/internal/server/hunting/dispatch.go b/go/internal/server/hunting/dispatch.go index c8354e9..1980cf4 100644 --- a/go/internal/server/hunting/dispatch.go +++ b/go/internal/server/hunting/dispatch.go @@ -142,10 +142,7 @@ func (s *Service) handleDispatch(path string, req []byte, session string) (int, out = wire.AppendVarint(out, 1, played) preview := dispatchCompletedRewards(job, played) remaining := d.AP * (job.Count - played) - bonus := remaining - if bonus > job.Bonus { - bonus = job.Bonus - } + bonus := min(remaining, job.Bonus) free := remaining - bonus if free > 0 { preview = append(preview, gamedata.BattleReward{Type: 21, Count: free}) @@ -170,10 +167,7 @@ func (s *Service) handleDispatch(path string, req []byte, session string) (int, played := dispatchPlayed(job, d.ClearTime) completed := dispatchCompletedRewards(job, played) remaining := d.AP * (job.Count - played) - refundBonus := remaining - if refundBonus > job.Bonus { - refundBonus = job.Bonus - } + refundBonus := min(remaining, job.Bonus) refundFree := remaining - refundBonus next := s.clone() next.Free += refundFree @@ -241,10 +235,7 @@ func (s *Service) handleDispatch(path string, req []byte, session string) (int, return code, nil, true, e } next := s.clone() - free := cost - if free > next.Free { - free = next.Free - } + free := min(cost, next.Free) bonus := cost - free next.Free -= free next.Bonus -= bonus @@ -314,10 +305,7 @@ func dispatchPlayed(j dispatchJob, seconds uint64) uint64 { if now <= j.Start { return 0 } - n := (now - j.Start) / (seconds * 1000) - if n > j.Count { - n = j.Count - } + n := min((now-j.Start)/(seconds*1000), j.Count) return n } func dispatchJobWire(j dispatchJob) []byte { @@ -345,7 +333,7 @@ func (s *Service) dispatchGrant(identity string, rs []gamedata.BattleReward) ([] if r.Type == 21 || r.Type == 23 { ap = append(ap, r) } else { - rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 } } s.mu.Unlock() @@ -370,7 +358,7 @@ func (s *Service) dispatchGrant(identity string, rs []gamedata.BattleReward) ([] case 21, 23: continue case 2, 3, 4, 12, 20: - currency = append(currency, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + currency = append(currency, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 default: items = append(items, r) } diff --git a/go/internal/server/hunting/dispatch_test.go b/go/internal/server/hunting/dispatch_test.go index f79fb9e..6bbbbde 100644 --- a/go/internal/server/hunting/dispatch_test.go +++ b/go/internal/server/hunting/dispatch_test.go @@ -99,9 +99,9 @@ func TestDispatchPreviewHasAPItemInfo(t *testing.T) { t.Fatal(e) } totals := map[uint64]uint64{} - wire.Walk(out, func(f wire.Field) error { + if err := wire.Walk(out, func(f wire.Field) error { if f.Number == 2 { - wire.Walk(f.Value, func(item wire.Field) error { + return wire.Walk(f.Value, func(item wire.Field) error { if item.Number == 1 { typ, _, _ := wire.Varint(item.Value, 3) n, _, _ := wire.Varint(item.Value, 4) @@ -111,7 +111,9 @@ func TestDispatchPreviewHasAPItemInfo(t *testing.T) { }) } return nil - }) + }); err != nil { + t.Fatal(err) + } if totals[21] != 10 || totals[23] != 2 || totals[4] != 0 { t.Fatalf("preview refund ItemInfo %+v", totals) } diff --git a/go/internal/server/hunting/dispatch_transaction_test.go b/go/internal/server/hunting/dispatch_transaction_test.go index d602df4..55a5dfc 100644 --- a/go/internal/server/hunting/dispatch_transaction_test.go +++ b/go/internal/server/hunting/dispatch_transaction_test.go @@ -27,7 +27,11 @@ func TestDispatchReceiptFailureRollsBackAPAndRewards(t *testing.T) { if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() inv, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) @@ -67,7 +71,11 @@ func TestDispatchReceiptFailureRollsBackAPAndRewards(t *testing.T) { if err != nil { t.Fatal(err) } - defer reopened.Close() + defer func() { + if err := reopened.Close(); err != nil { + t.Error(err) + } + }() for _, name := range []string{"hunting", "huntdispatch"} { raw, err := reopened.Load(name) if err != nil || raw != nil { diff --git a/go/internal/server/hunting/hunting.go b/go/internal/server/hunting/hunting.go index d35adb5..fba117e 100644 --- a/go/internal/server/hunting/hunting.go +++ b/go/internal/server/hunting/hunting.go @@ -10,7 +10,9 @@ import ( "encoding/binary" "encoding/json" "fmt" + "maps" "math" + "slices" "strconv" "sync" "time" @@ -267,9 +269,6 @@ func monsterWire(m gamedata.HuntingMonster, active bool) []byte { } return b } -func monsters(d *gamedata.HuntingPack, g gamedata.HuntingGround) [][]byte { - return monstersWithState(d, g, nil) -} func monstersWithState(d *gamedata.HuntingPack, g gamedata.HuntingGround, defeated []uint64) [][]byte { dead := map[uint64]bool{} for _, id := range defeated { @@ -328,10 +327,8 @@ func (s *Service) validate(pack int, monster, deck uint64) (*gamedata.HuntingPac } } } - for _, dead := range st.Defeated { - if dead == monster { - return nil, g, m, fmt.Errorf("hunting: defeated monster requires reentry") - } + if slices.Contains(st.Defeated, monster) { + return nil, g, m, fmt.Errorf("hunting: defeated monster requires reentry") } known := false for _, id := range m.Decks { @@ -379,7 +376,7 @@ func (s *Service) CompleteBattle(pack int, mode, monster, deck uint64, receipt s stack := make([]gamedata.BattleReward, 0) for _, r := range rewards { if r.Type == 2 || r.Type == 3 || r.Type == 4 || r.Type == 12 || r.Type == 20 { - currency = append(currency, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + currency = append(currency, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 } else { stack = append(stack, r) } @@ -389,7 +386,7 @@ func (s *Service) CompleteBattle(pack int, mode, monster, deck uint64, receipt s if s.grant != nil { rs := make([]gamedata.Reward, len(rewards)) for i, r := range rewards { - rs[i] = gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count} + rs[i] = gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count} //nolint:staticcheck // S1016 } s.mu.Unlock() grantedBundle, err = s.grant(identity, rs) @@ -465,9 +462,7 @@ func (s *Service) CompleteBattle(pack int, mode, monster, deck uint64, receipt s if err != nil { return nil, nil, err } - for key, value := range latest { - ledger[key] = value - } + maps.Copy(ledger, latest) ledger[receipt] = battleReceipt{Fingerprint: fingerprint, Bundle: bundle, Monsters: updates} if err := s.saveBattleReceipts(ledger); err != nil { return nil, nil, err @@ -477,13 +472,9 @@ func (s *Service) CompleteBattle(pack int, mode, monster, deck uint64, receipt s func (s *Service) clone() snapshot { next := s.state next.Packs = map[string]packState{} - for k, v := range s.state.Packs { - next.Packs[k] = v - } + maps.Copy(next.Packs, s.state.Packs) next.Receipts = map[string]bool{} - for k, v := range s.state.Receipts { - next.Receipts[k] = v - } + maps.Copy(next.Receipts, s.state.Receipts) return next } func (s *Service) persist(next snapshot) error { diff --git a/go/internal/server/hunting/hunting_test.go b/go/internal/server/hunting/hunting_test.go index be7caa0..a226198 100644 --- a/go/internal/server/hunting/hunting_test.go +++ b/go/internal/server/hunting/hunting_test.go @@ -129,7 +129,7 @@ func TestHuntingRepeatEnterReceiptAPAndDefeated(t *testing.T) { t.Fatal(err) } var inactive bool - wire.Walk(info, func(f wire.Field) error { + if err := wire.Walk(info, func(f wire.Field) error { if f.Number == 4 { id, _, _ := wire.Varint(f.Value, 1) active, _, _ := wire.Varint(f.Value, 6) @@ -138,7 +138,9 @@ func TestHuntingRepeatEnterReceiptAPAndDefeated(t *testing.T) { } } return nil - }) + }); err != nil { + t.Fatal(err) + } if !inactive { t.Fatal("info resurrected defeated monster") } @@ -264,7 +266,9 @@ func TestHuntingRewardAPCallbackDoesNotDeadlock(t *testing.T) { s.AttachRewards(func(id string, _ []gamedata.Reward) ([]byte, error) { return nil, s.ExchangeAPOnce(id, nil, []gamedata.Reward{{Type: 21, Count: 3}}) }) - s.EnsureForPack(1) + if _, err := s.EnsureForPack(1); err != nil { + t.Fatal(err) + } done := make(chan error, 1) go func() { _, _, e := s.CompleteBattle(1, 5, 1, 1, "apreward"); done <- e }() select { @@ -291,7 +295,9 @@ func TestHuntingAPDailyRefreshPreservesBonus(t *testing.T) { } now := time.Date(2026, 10, 4, 23, 59, 0, 0, time.UTC) s.now = func() time.Time { return now } - s.HuntingAP() + if _, _, err := s.HuntingAP(); err != nil { + t.Fatal(err) + } now = now.Add(2 * time.Minute) free, bonus, e := s.HuntingAP() if e != nil || free != 90 || bonus != 7 { diff --git a/go/internal/server/hunting/transaction_test.go b/go/internal/server/hunting/transaction_test.go index ff0cd09..3984ba2 100644 --- a/go/internal/server/hunting/transaction_test.go +++ b/go/internal/server/hunting/transaction_test.go @@ -17,7 +17,11 @@ func TestHuntingSettlementRollsBackAPWalletInventoryAndProgress(t *testing.T) { if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() inv, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) @@ -71,7 +75,11 @@ func TestHuntingSettlementRollsBackAPWalletInventoryAndProgress(t *testing.T) { if err != nil { t.Fatal(err) } - defer reopened.Close() + defer func() { + if err := reopened.Close(); err != nil { + t.Error(err) + } + }() after, err := reopened.Load("hunting") if err != nil || string(after) != string(before) { t.Fatalf("hunting progress survived rollback: %s %v", after, err) diff --git a/go/internal/server/logging/logging_test.go b/go/internal/server/logging/logging_test.go index eacd3bd..83c96b9 100644 --- a/go/internal/server/logging/logging_test.go +++ b/go/internal/server/logging/logging_test.go @@ -57,9 +57,8 @@ func TestGroupsAndConcurrentDerivedLoggers(t *testing.T) { h, _ := NewHandler(&out, Options{Color: ColorAlways}) logger := slog.New(h).With("service", "server").WithGroup("request").With("id", 7) var workers sync.WaitGroup - for i := 0; i < 50; i++ { - workers.Add(1) - go func() { defer workers.Done(); logger.Info("handled", slog.Group("result", "ok", true)) }() + for range 50 { + workers.Go(func() { logger.Info("handled", slog.Group("result", "ok", true)) }) } workers.Wait() lines := strings.Split(strings.TrimSpace(out.String()), "\n") @@ -78,7 +77,11 @@ func TestAutoRedirectedOutputIsPlain(t *testing.T) { if err != nil { t.Fatal(err) } - defer file.Close() + defer func() { + if err := file.Close(); err != nil { + t.Error(err) + } + }() h, _ := NewHandler(file, Options{}) slog.New(h).Warn("redirected") if _, err := file.Seek(0, 0); err != nil { diff --git a/go/internal/server/mail/attendance_test.go b/go/internal/server/mail/attendance_test.go index f2f8c05..4059fb5 100644 --- a/go/internal/server/mail/attendance_test.go +++ b/go/internal/server/mail/attendance_test.go @@ -134,7 +134,7 @@ func TestAttendanceEconomyDoesNotRegrantExistingOpenedMail(t *testing.T) { store := stateio.NewMemory() seed := &Starter{Version: "2.35.10", MailCount: 1, MaxMailID: 100} design := &attendanceDesign{} - s, _, wallet := attendanceMailFixture(t, store, seed, design) + s, _, _ := attendanceMailFixture(t, store, seed, design) now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) s.now = func() time.Time { return now } if err := s.EnqueueCompensation("existing:grant", "奖励", "请领取", []gamedata.Reward{{Type: 4, Count: 7}}, now); err != nil { diff --git a/go/internal/server/mail/cash.go b/go/internal/server/mail/cash.go index 9fd43bf..8b29d55 100644 --- a/go/internal/server/mail/cash.go +++ b/go/internal/server/mail/cash.go @@ -3,6 +3,7 @@ package mail import ( "errors" "fmt" + "maps" "math" "reflect" "sort" @@ -25,9 +26,7 @@ func (s *Service) AttachCashRewards(e CashRewardEconomy, templates map[uint64]bo } s.cashEconomy = e s.cashTemplates = make(map[uint64]bool, len(templates)) - for id, ok := range templates { - s.cashTemplates[id] = ok - } + maps.Copy(s.cashTemplates, templates) return nil } func (s *Service) IssueCashOnce(identity string, template uint64, rewards []gamedata.Reward, sentAt time.Time) error { diff --git a/go/internal/server/mail/grant_spool_test.go b/go/internal/server/mail/grant_spool_test.go index 37bf660..ca3a7e7 100644 --- a/go/internal/server/mail/grant_spool_test.go +++ b/go/internal/server/mail/grant_spool_test.go @@ -68,7 +68,7 @@ func TestGrantSpoolImportsDynamicMailOnceAcrossOpenAndRestart(t *testing.T) { if len(service.Starter.Mails) != 0 { t.Fatal("starter changed") } - for i := 0; i < 2; i++ { + for range 2 { code, response, handled, err := service.Handle("/MailInfo", request) if err != nil || !handled || code != packetCode { t.Fatalf("code=%d handled=%v err=%v", code, handled, err) @@ -146,7 +146,11 @@ func TestGrantSpoolPersistsIssuedIdentityInSQLiteRequestTransaction(t *testing.T if err != nil { t.Fatal(err) } - defer repository.Close() + defer func() { + if err := repository.Close(); err != nil { + t.Error(err) + } + }() service, _, _ = spoolTestService(t, repository) if err := service.AttachGrantSpoolPath(path); err != nil { t.Fatal(err) @@ -319,7 +323,9 @@ func TestGrantSpoolOneUseContentTicketItemDBInfoPersistsAndIsIdempotent(t *testi t.Fatalf("content ticket not stored: %+v", got) } service, inventory, _ = spoolTestService(t, store) - service.AttachContentTickets(&gamedata.GachaContentTicketDesign{IDs: map[uint64]bool{450030: true, 660003: true}}) + if err := service.AttachContentTickets(&gamedata.GachaContentTicketDesign{IDs: map[uint64]bool{450030: true, 660003: true}}); err != nil { + t.Fatal(err) + } if err := service.AttachGrantSpoolPath(path); err != nil { t.Fatal(err) } diff --git a/go/internal/server/mail/mail.go b/go/internal/server/mail/mail.go index bde1538..1cd2f82 100644 --- a/go/internal/server/mail/mail.go +++ b/go/internal/server/mail/mail.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "sort" "strconv" "sync" @@ -382,7 +383,7 @@ func OpenService(storage stateio.Store, starter *Starter, inventory *player.Inve } s.state.NextDynamicMailID = starter.MaxMailID + 1 } - sort.Slice(s.state.Opened, func(i, j int) bool { return s.state.Opened[i] < s.state.Opened[j] }) + slices.Sort(s.state.Opened) for i := 1; i < len(s.state.Opened); i++ { if s.state.Opened[i] == s.state.Opened[i-1] { return nil, errors.New("mail: duplicate opened ID") @@ -522,7 +523,7 @@ func (s *Service) info() []byte { for id := range s.dynamic { dynamicIDs = append(dynamicIDs, id) } - sort.Slice(dynamicIDs, func(i, j int) bool { return dynamicIDs[i] < dynamicIDs[j] }) + slices.Sort(dynamicIDs) for _, id := range dynamicIDs { if s.dynamic[id].IsCash || containsID(s.state.Opened, id) { continue @@ -643,7 +644,7 @@ func (s *Service) open(request []byte) ([]byte, error) { if reward.ID == 0 || reward.Count == 0 { return nil, errors.New("mail: invalid item reward") } - items = append(items, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) + items = append(items, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) //nolint:staticcheck // S1016 } } if _, err := s.wallet.GrantQuestOnce(identity+":currency", rewards); err != nil { @@ -699,7 +700,7 @@ func (s *Service) open(request []byte) ([]byte, error) { newHistory = append(newHistory, history) } } - sort.Slice(next.Opened, func(i, j int) bool { return next.Opened[i] < next.Opened[j] }) + slices.Sort(next.Opened) if err := s.commitWithEntries(next, changes); err != nil { return nil, err } @@ -1064,10 +1065,5 @@ func (s *Service) enqueueCompensations(grants []compensation) error { } func containsID(values []uint64, want uint64) bool { - for _, value := range values { - if value == want { - return true - } - } - return false + return slices.Contains(values, want) } diff --git a/go/internal/server/missions/achievement_progress_test.go b/go/internal/server/missions/achievement_progress_test.go index a2be247..22e9304 100644 --- a/go/internal/server/missions/achievement_progress_test.go +++ b/go/internal/server/missions/achievement_progress_test.go @@ -23,7 +23,9 @@ func TestAchievementClearRequiresProgressBeforeAnyBatchReward(t *testing.T) { t.Fatal(err) } progress := testAchievementProgress(5) - s.AttachAchievementProgress(&progress) + if err := s.AttachAchievementProgress(&progress); err != nil { + t.Fatal(err) + } info := wire.AppendVarint(nil, 1, 987) info = wire.AppendVarint(info, 2, 1) info = wire.AppendVarint(info, 2, 2) @@ -61,11 +63,15 @@ func TestAchievementCurrencyRewardsCreditWalletAndReplay(t *testing.T) { if err != nil { t.Fatal(err) } - s.AttachWallet(wallet) + if err := s.AttachWallet(wallet); err != nil { + t.Fatal(err) + } p := testAchievementProgress(1) - s.AttachAchievementProgress(&p) + if err := s.AttachAchievementProgress(&p); err != nil { + t.Fatal(err) + } req := wire.AppendBytes(wire.AppendVarint(nil, 1, 1), 3, wire.AppendVarint(wire.AppendVarint(nil, 1, 987), 2, 1)) - for i := 0; i < 2; i++ { + for range 2 { if _, _, _, err := s.Handle("/AchievementClear", req); err != nil { t.Fatal(err) } @@ -92,7 +98,9 @@ func TestAchievementTierOrderUsesActualIDsAndAllowsUnorderedBatch(t *testing.T) t.Fatal(err) } p := testAchievementProgress(2) - s.AttachAchievementProgress(&p) + if err := s.AttachAchievementProgress(&p); err != nil { + t.Fatal(err) + } request := func(ids ...uint64) []byte { info := wire.AppendVarint(nil, 1, 987) for _, id := range ids { diff --git a/go/internal/server/missions/events.go b/go/internal/server/missions/events.go index e9f4431..9f9e835 100644 --- a/go/internal/server/missions/events.go +++ b/go/internal/server/missions/events.go @@ -126,10 +126,7 @@ func (s *Service) recordEventLocked(conditionType, subType, count uint64, unlock if before >= target { continue } - increment := count - if increment > target-before { - increment = target - before - } + increment := min(count, target-before) next.Progress[name] = before + increment if next.Progress[name] == target { s.applyCompletionDependencies(&next, key) diff --git a/go/internal/server/missions/missions.go b/go/internal/server/missions/missions.go index b54a780..d076f58 100644 --- a/go/internal/server/missions/missions.go +++ b/go/internal/server/missions/missions.go @@ -7,6 +7,8 @@ import ( "encoding/json" "errors" "fmt" + "maps" + "slices" "sort" "strconv" "strings" @@ -208,10 +210,7 @@ func (s *Service) applyCompletionDependencies(next *snapshot, completed gamedata if before >= target { continue } - next.Progress[name] = before + 1 - if next.Progress[name] > target { - next.Progress[name] = target - } + next.Progress[name] = min(before+1, target) if before < target && next.Progress[name] >= target { queue = append(queue, key) } @@ -1004,7 +1003,7 @@ func (s *Service) grantRewards(identity string, rewards []gamedata.Reward) ([]pl // server-dynamic. It is intentionally not fabricated as ItemDBInfo. continue } - stack = append(stack, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) + stack = append(stack, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) //nolint:staticcheck // S1016 } if len(stack) == 0 { return nil, nil @@ -1031,14 +1030,6 @@ func rewardBundle(items []player.Item) []byte { return bundle } -func battleRewards(rewards []gamedata.Reward) []gamedata.BattleReward { - result := make([]gamedata.BattleReward, len(rewards)) - for i, reward := range rewards { - result[i] = gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count} - } - return result -} - func (s *Service) commit(next snapshot) error { sort.Strings(next.Completed) sort.Strings(next.Claimed) @@ -1073,18 +1064,11 @@ func equalProgress(a, b map[string]uint64) bool { func cloneSnapshot(in snapshot) snapshot { progress := make(map[string]uint64, len(in.Progress)) - for key, value := range in.Progress { - progress[key] = value - } + maps.Copy(progress, in.Progress) return snapshot{Version: in.Version, DailyPeriod: in.DailyPeriod, WeeklyPeriod: in.WeeklyPeriod, Completed: append([]string(nil), in.Completed...), Claimed: append([]string(nil), in.Claimed...), Progress: progress} } func contains(values []string, want string) bool { - for _, value := range values { - if value == want { - return true - } - } - return false + return slices.Contains(values, want) } func equalStrings(a, b []string) bool { if len(a) != len(b) { diff --git a/go/internal/server/monsterhunt/monsterhunt_test.go b/go/internal/server/monsterhunt/monsterhunt_test.go index 85fcc29..84d99d1 100644 --- a/go/internal/server/monsterhunt/monsterhunt_test.go +++ b/go/internal/server/monsterhunt/monsterhunt_test.go @@ -126,7 +126,7 @@ func TestPresetSlotCurrencyAndIdempotency(t *testing.T) { req := wire.AppendVarint(nil, 1, 41) req = wire.AppendVarint(req, 2, 1) before := s.state.Slots - for i := 0; i < 2; i++ { + for range 2 { if _, _, _, e := s.HandleSession("/MonsterHuntPresetSlotAdd", req, "session"); e != nil { t.Fatal(e) } diff --git a/go/internal/server/monsterhunt/rewards.go b/go/internal/server/monsterhunt/rewards.go index 97bbd16..0dbbcb9 100644 --- a/go/internal/server/monsterhunt/rewards.go +++ b/go/internal/server/monsterhunt/rewards.go @@ -41,7 +41,6 @@ func (s *Service) rankWire(u user) []byte { func (s *Service) rankRewards(d *gamedata.MonsterHunt, group uint64) []gamedata.BattleReward { var selected *gamedata.MonsterHuntRankReward for _, r := range d.Ranks[group] { - r := r threshold := float64(1) if r.Type == 1 { threshold = 100 @@ -59,7 +58,7 @@ func (s *Service) grant(identity string, rewards []gamedata.BattleReward) ([]byt if s.rewardGrant != nil { rs := make([]gamedata.Reward, len(rewards)) for i, r := range rewards { - rs[i] = gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count} + rs[i] = gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count} //nolint:staticcheck // S1016 } return s.rewardGrant(identity, rs) } @@ -68,7 +67,7 @@ func (s *Service) grant(identity string, rewards []gamedata.BattleReward) ([]byt for _, r := range rewards { switch r.Type { case 2, 3, 4, 12, 20: - currency = append(currency, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + currency = append(currency, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 default: stack = append(stack, r) } diff --git a/go/internal/server/monsterhunt/transaction_test.go b/go/internal/server/monsterhunt/transaction_test.go index d7edd8d..d115f6a 100644 --- a/go/internal/server/monsterhunt/transaction_test.go +++ b/go/internal/server/monsterhunt/transaction_test.go @@ -16,7 +16,11 @@ func TestPresetPurchaseRollsBackCurrencyAndSlotTogether(t *testing.T) { if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() wallet, err := player.OpenWallet(repo, player.Currency{Gold: 100000}) if err != nil { t.Fatal(err) @@ -44,7 +48,11 @@ func TestPresetPurchaseRollsBackCurrencyAndSlotTogether(t *testing.T) { if err != nil { t.Fatal(err) } - defer reopened.Close() + defer func() { + if err := reopened.Close(); err != nil { + t.Error(err) + } + }() if raw, err := reopened.Load("monsterhunt"); err != nil || raw != nil { t.Fatalf("slot survived rollback: %s %v", raw, err) } diff --git a/go/internal/server/npcinn/inn.go b/go/internal/server/npcinn/inn.go index 1444a20..c851296 100644 --- a/go/internal/server/npcinn/inn.go +++ b/go/internal/server/npcinn/inn.go @@ -182,10 +182,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) if available > math.MaxUint64/divisor { return fail(errors.New("npcinn: recovery amount overflow")) } - heal := available * divisor / rate - if heal > missing { - heal = missing - } + heal := min(available*divisor/rate, missing) cost = heal * rate / divisor c.HP = current + heal } diff --git a/go/internal/server/npcinn/inn_test.go b/go/internal/server/npcinn/inn_test.go index 0ede1e5..5cd7ed9 100644 --- a/go/internal/server/npcinn/inn_test.go +++ b/go/internal/server/npcinn/inn_test.go @@ -28,7 +28,9 @@ func innTestServiceWithStore(t *testing.T, store stateio.Store, gold, level, sta if err != nil { t.Fatal(err) } - chars.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }) + if err := chars.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }); err != nil { + t.Fatal(err) + } if err = chars.EnsurePersisted(); err != nil { t.Fatal(err) } @@ -76,12 +78,18 @@ func TestInnReceiptFailureRollsBackGoldAndHealthTogether(t *testing.T) { if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { t.Fatal(err) } - repo.Close() + if err := repo.Close(); err != nil { + t.Fatal(err) + } repo, err = accountstate.Open(path) if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() next, chars, wallet, _ := innTestServiceWithStore(t, repo, 0, 21, 1) hp, _ := chars.CurrentHealth(71) if hp != 10 || wallet.Snapshot().Gold != 10 { diff --git a/go/internal/server/npcshop/sale_validation_test.go b/go/internal/server/npcshop/sale_validation_test.go index 8175875..3268e8a 100644 --- a/go/internal/server/npcshop/sale_validation_test.go +++ b/go/internal/server/npcshop/sale_validation_test.go @@ -190,7 +190,7 @@ func TestSQLiteMixedSaleWalletInventoryReceiptRollbackAndRestartRetry(t *testing req := saleRequest(1, saleLine(granted[0].InvenIndex, 19, 3, 100), saleLine(granted[1].InvenIndex, 23, 4, 100)) var wallet *player.Wallet var response []byte - for phase := 0; phase < 3; phase++ { + for phase := range 3 { items, err = player.OpenInventory(repo, starter) if err != nil { t.Fatal(err) diff --git a/go/internal/server/npcshop/shop.go b/go/internal/server/npcshop/shop.go index 3ed521f..923b95f 100644 --- a/go/internal/server/npcshop/shop.go +++ b/go/internal/server/npcshop/shop.go @@ -8,7 +8,7 @@ import ( "encoding/json" "fmt" "math" - "sort" + "slices" "strconv" "sync" "time" @@ -160,7 +160,7 @@ func (s *Service) shopWire(v snapshot, id uint64) []byte { for n := range s.design.Products[id] { ids = append(ids, n) } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) for _, n := range ids { p := wire.AppendVarint(nil, 1, n) p = wire.AppendVarint(p, 2, s.bought(v, id, n)) @@ -175,7 +175,7 @@ func (s *Service) allShops(v snapshot, field int) []byte { ids = append(ids, id) } } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) var b []byte for _, id := range ids { b = wire.AppendBytes(b, field, s.shopWire(v, id)) @@ -305,7 +305,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) for id := range changed { ids = append(ids, id) } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) for _, id := range ids { b = wire.AppendBytes(b, 2, s.shopWire(v, id)) } diff --git a/go/internal/server/npcshop/shop_test.go b/go/internal/server/npcshop/shop_test.go index 6858813..439a959 100644 --- a/go/internal/server/npcshop/shop_test.go +++ b/go/internal/server/npcshop/shop_test.go @@ -44,7 +44,11 @@ func TestFailedSaleReceiptRollsBackConcreteInventoryAndReward(t *testing.T) { if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() starter := &player.Starter{Version: versionconfig.State()} items, err := player.OpenInventory(repo, starter) if err != nil { @@ -82,7 +86,11 @@ func TestFailedSaleReceiptRollsBackConcreteInventoryAndReward(t *testing.T) { if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() reopened, err := player.OpenInventory(repo, starter) if err != nil { t.Fatal(err) diff --git a/go/internal/server/player/char_awake.go b/go/internal/server/player/char_awake.go index aa78a83..e8fc075 100644 --- a/go/internal/server/player/char_awake.go +++ b/go/internal/server/player/char_awake.go @@ -3,7 +3,7 @@ package player import ( "errors" "fmt" - "sort" + "slices" "strconv" "bd2server/internal/server/gamedata" @@ -60,7 +60,7 @@ func (s *CharAwakeService) info(request []byte) (int, []byte, bool, error) { for id := range states { ids = append(ids, id) } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) var response []byte for _, id := range ids { progress := states[id] @@ -167,7 +167,7 @@ func (s *CharAwakeService) imprintLevelUp(request []byte) (int, []byte, bool, er } identity := "char-imprint:" + strconv.FormatUint(uniqueID, 10) for _, level := range levels { - identity += ":" + strconv.FormatUint(level, 10) + identity += ":" + strconv.FormatUint(level, 10) //nolint:modernize // stringsbuilder } if gold != 0 { if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil { diff --git a/go/internal/server/player/char_awake_test.go b/go/internal/server/player/char_awake_test.go index 0018f5a..a254406 100644 --- a/go/internal/server/player/char_awake_test.go +++ b/go/internal/server/player/char_awake_test.go @@ -32,7 +32,7 @@ func newCharAwakeHarness(t *testing.T) (*CharAwakeService, *CollectionStore, *In } characters := &CharacterStore{characters: []Character{{InvenIndex: 77, ID: 354, Level: 100}}} character := gamedata.CharAwakeCharacter{UniqueCharID: 35, Active: true} - for i := 0; i < 3; i++ { + for i := range 3 { character.ImprintGrowth[i] = []gamedata.CharAwakeGrowth{{ ID: uint64(i + 1), StatType: uint64(i*2 + 1), StatValue: float64(i + 1), Costs: []gamedata.CharAwakeCost{{Type: 8, ID: uint64(701 + i), Count: 1}, {Type: 4, Count: 100}}, diff --git a/go/internal/server/player/collection.go b/go/internal/server/player/collection.go index bf7512c..4e784bf 100644 --- a/go/internal/server/player/collection.go +++ b/go/internal/server/player/collection.go @@ -5,7 +5,9 @@ import ( "encoding/json" "errors" "fmt" + "maps" "math" + "slices" "sort" "strconv" "strings" @@ -711,10 +713,7 @@ func (s *CollectionStore) grantCostumesSorted(identity string, costumeIDs, sortI } if base, found := findCostume(s.base, costumeID); found { key := strconv.FormatUint(s.base[base].InvenIndex, 10) - before := next.BaseCostumeLevels[key] - if before < s.base[base].Level { - before = s.base[base].Level - } + before := max(next.BaseCostumeLevels[key], s.base[base].Level) if before < maxLevel { next.BaseCostumeLevels[key] = before + 1 grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: s.base[base].InvenIndex, CostumeID: costumeID, Before: before, After: before + 1, SortID: sortID}) @@ -1072,7 +1071,7 @@ func (s *CollectionStore) ActivateCostumePotential(costumeIndex uint64, nodes [] next := cloneCollection(s.data) key := strconv.FormatUint(costumeIndex, 10) next.CostumePotential[key] = append(next.CostumePotential[key], nodes...) - sort.Slice(next.CostumePotential[key], func(i, j int) bool { return next.CostumePotential[key][i] < next.CostumePotential[key][j] }) + slices.Sort(next.CostumePotential[key]) return s.commit(next) } @@ -1202,54 +1201,36 @@ func cloneCollection(in collectionSnapshot) collectionSnapshot { out.Characters = append([]Character(nil), in.Characters...) out.Costumes = append([]Costume(nil), in.Costumes...) out.BaseCostumeLevels = make(map[string]uint64, len(in.BaseCostumeLevels)) - for k, v := range in.BaseCostumeLevels { - out.BaseCostumeLevels[k] = v - } + maps.Copy(out.BaseCostumeLevels, in.BaseCostumeLevels) out.CostumePotential = make(map[string][]uint64, len(in.CostumePotential)) for k, v := range in.CostumePotential { out.CostumePotential[k] = append([]uint64(nil), v...) } out.CostumeBurstLevels = make(map[string]uint64, len(in.CostumeBurstLevels)) - for k, v := range in.CostumeBurstLevels { - out.CostumeBurstLevels[k] = v - } + maps.Copy(out.CostumeBurstLevels, in.CostumeBurstLevels) out.CostumeBurstUpgrades = make(map[string]CostumeBurstUpgradeRecord, len(in.CostumeBurstUpgrades)) for k, v := range in.CostumeBurstUpgrades { v.Body = append([]byte(nil), v.Body...) out.CostumeBurstUpgrades[k] = v } out.CharAwake = make(map[string]CharAwakeProgress, len(in.CharAwake)) - for k, v := range in.CharAwake { - out.CharAwake[k] = v - } + maps.Copy(out.CharAwake, in.CharAwake) out.GachaSelections = make(map[string][]GachaSelection, len(in.GachaSelections)) for k, v := range in.GachaSelections { out.GachaSelections[k] = append([]GachaSelection(nil), v...) } out.GachaSelectionChanges = make(map[string]uint64, len(in.GachaSelectionChanges)) - for k, v := range in.GachaSelectionChanges { - out.GachaSelectionChanges[k] = v - } + maps.Copy(out.GachaSelectionChanges, in.GachaSelectionChanges) out.StepUpProgress = make(map[string]uint64, len(in.StepUpProgress)) - for k, v := range in.StepUpProgress { - out.StepUpProgress[k] = v - } + maps.Copy(out.StepUpProgress, in.StepUpProgress) out.GachaUsers = make(map[string]GachaUserState, len(in.GachaUsers)) - for k, v := range in.GachaUsers { - out.GachaUsers[k] = v - } + maps.Copy(out.GachaUsers, in.GachaUsers) out.GachaFixed = make(map[string]GachaFixedState, len(in.GachaFixed)) - for k, v := range in.GachaFixed { - out.GachaFixed[k] = v - } + maps.Copy(out.GachaFixed, in.GachaFixed) out.GachaApplied = make(map[string]bool, len(in.GachaApplied)) - for k, v := range in.GachaApplied { - out.GachaApplied[k] = v - } + maps.Copy(out.GachaApplied, in.GachaApplied) out.GachaPointExchange = make(map[string]GachaPointExchange, len(in.GachaPointExchange)) - for k, v := range in.GachaPointExchange { - out.GachaPointExchange[k] = v - } + maps.Copy(out.GachaPointExchange, in.GachaPointExchange) out.Grants = make(map[string]CollectionGrant, len(in.Grants)) for k, v := range in.Grants { out.Grants[k] = cloneGrant(v) diff --git a/go/internal/server/player/collection_entries.go b/go/internal/server/player/collection_entries.go index d142e59..e0c1b7c 100644 --- a/go/internal/server/player/collection_entries.go +++ b/go/internal/server/player/collection_entries.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "reflect" + "slices" "sort" "strconv" @@ -117,7 +118,7 @@ func loadIndexedEntries[T any](store stateio.EntryStore, bucket string, index fu } indices = append(indices, parsed) } - sort.Slice(indices, func(i, j int) bool { return indices[i] < indices[j] }) + slices.Sort(indices) result := make([]T, 0, len(indices)) for _, id := range indices { result = append(result, values[strconv.FormatUint(id, 10)]) diff --git a/go/internal/server/player/content_open_test.go b/go/internal/server/player/content_open_test.go index e22ad07..4a82d29 100644 --- a/go/internal/server/player/content_open_test.go +++ b/go/internal/server/player/content_open_test.go @@ -223,7 +223,11 @@ func TestContentOpenSQLiteReceiptRestartAndRollback(t *testing.T) { if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() service, inventory := contentOpenHarness(t, repo, true, 3) if fail { if contentOpenTicketCount(inventory, 42) != 0 { diff --git a/go/internal/server/player/costume_burst_test.go b/go/internal/server/player/costume_burst_test.go index 2ec233c..263709e 100644 --- a/go/internal/server/player/costume_burst_test.go +++ b/go/internal/server/player/costume_burst_test.go @@ -234,7 +234,11 @@ func TestCostumeBurstSQLiteRollbackIsAtomic(t *testing.T) { if err != nil { t.Fatal(err) } - defer reopened.Close() + defer func() { + if err := reopened.Close(); err != nil { + t.Error(err) + } + }() reloadedInventory, err := OpenInventory(reopened, starter) if err != nil { t.Fatal(err) diff --git a/go/internal/server/player/costume_potential.go b/go/internal/server/player/costume_potential.go index 85cdacc..91121d4 100644 --- a/go/internal/server/player/costume_potential.go +++ b/go/internal/server/player/costume_potential.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" "math" - "sort" + "slices" "strconv" "strings" "sync" @@ -225,7 +225,7 @@ func (s *CostumePotentialService) Handle(path string, request []byte) (int, []by return 0, nil, true, errors.New("player: insufficient gold for costume potential") } sortedNodes := append([]uint64(nil), nodes...) - sort.Slice(sortedNodes, func(i, j int) bool { return sortedNodes[i] < sortedNodes[j] }) + slices.Sort(sortedNodes) parts := make([]string, len(sortedNodes)) for i, id := range sortedNodes { parts[i] = strconv.FormatUint(id, 10) diff --git a/go/internal/server/player/costume_potential_connect.go b/go/internal/server/player/costume_potential_connect.go index 31b8baf..e214666 100644 --- a/go/internal/server/player/costume_potential_connect.go +++ b/go/internal/server/player/costume_potential_connect.go @@ -56,8 +56,10 @@ func (s *CostumePotentialService) connect(request []byte) (int, []byte, bool, er return fail(e) } } + seqFields := 0 if err = wire.Walk(request, func(f wire.Field) error { if f.Number == 1 { + seqFields++ if f.Type != 0 { return fmt.Errorf("player: invalid potential sequence wire") } @@ -66,13 +68,6 @@ func (s *CostumePotentialService) connect(request []byte) (int, []byte, bool, er }); err != nil { return fail(err) } - seqFields := 0 - wire.Walk(request, func(f wire.Field) error { - if f.Number == 1 { - seqFields++ - } - return nil - }) if seqFields != 1 { return fail(fmt.Errorf("player: duplicate potential sequence")) } @@ -167,10 +162,7 @@ func (s *CostumePotentialService) connect(request []byte) (int, []byte, bool, er if e != nil { return fail(e) } - hp := oldHP[c.InvenIndex] - if hp > maximum { - hp = maximum - } + hp := min(oldHP[c.InvenIndex], maximum) if e = s.characters.SetCurrentHealth(c.InvenIndex, hp); e != nil { return fail(e) } diff --git a/go/internal/server/player/costume_potential_connect_test.go b/go/internal/server/player/costume_potential_connect_test.go index b8c47c8..82f0325 100644 --- a/go/internal/server/player/costume_potential_connect_test.go +++ b/go/internal/server/player/costume_potential_connect_test.go @@ -46,14 +46,18 @@ func connectTestService(t *testing.T, store stateio.Store) (*CostumePotentialSer if e != nil { t.Fatal(e) } - s.AttachConnectStore(store) + if err := s.AttachConnectStore(store); err != nil { + t.Fatal(err) + } s.BeginSession("test") - chars.AttachMaxHealth(func(c Character) (uint64, error) { + if err := chars.AttachMaxHealth(func(c Character) (uint64, error) { if c.ConnectPotentialCostume == 200 { return 50, nil } return 100, nil - }) + }); err != nil { + t.Fatal(err) + } return s, chars } func connectRequest(seq uint64, rows ...[2]uint64) []byte { @@ -153,7 +157,9 @@ func TestPotentialConnectSQLiteFailureRollsBackLinkAndHealth(t *testing.T) { t.Fatal(e) } s, _ := connectTestService(t, repo) - s.AttachConnectStore(connectFailStore{repo}) + if err := s.AttachConnectStore(connectFailStore{repo}); err != nil { + t.Fatal(err) + } op, e := repo.BeginOperation() if e != nil { t.Fatal(e) @@ -165,12 +171,18 @@ func TestPotentialConnectSQLiteFailureRollsBackLinkAndHealth(t *testing.T) { if e = op.Rollback(); e != nil && !errors.Is(e, stateio.ErrStateRecoveryRequired) { t.Fatal(e) } - repo.Close() + if err := repo.Close(); err != nil { + t.Fatal(err) + } repo, e = accountstate.Open(path) if e != nil { t.Fatal(e) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() next, chars := connectTestService(t, repo) c, _ := chars.Find(77) if c.ConnectPotentialCostume != 0 || c.HP != 80 { @@ -223,8 +235,12 @@ func TestPotentialConnectionCollectionCharacterFallbackPersistsAcrossRestart(t * t.Fatal("collection connection lost after restart") } s.collection = collection - chars.AttachCollection(collection) - s.AttachConnectStore(store) + if err := chars.AttachCollection(collection); err != nil { + t.Fatal(err) + } + if err := s.AttachConnectStore(store); err != nil { + t.Fatal(err) + } s.BeginSession("test") _, again, _, e := s.Handle("/CostumePotentialConnect", b) if e != nil || !bytes.Equal(response, again) { diff --git a/go/internal/server/player/eat_food_test.go b/go/internal/server/player/eat_food_test.go index d3cba2e..78ec7a9 100644 --- a/go/internal/server/player/eat_food_test.go +++ b/go/internal/server/player/eat_food_test.go @@ -106,13 +106,19 @@ func TestEatFoodAutoSharesStacksValidatesWholeRequestAndReplaysSQLite(t *testing }); err != nil || len(resultIndices) != 2 || resultIndices[0] != 77 || resultIndices[1] != 78 { t.Fatalf("auto response=%v err=%v", resultIndices, err) } - repo.Close() + if err := repo.Close(); err != nil { + t.Fatal(err) + } repo, err = accountstate.Open(path) if err != nil { t.Fatal(err) } - defer repo.Close() - s, inventory, characters = foodTestService(t, repo) + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() + s, inventory, _ = foodTestService(t, repo) code, replay, _, err := s.Handle("/EatFoodAuto", request) if err != nil || code != 27 || !bytes.Equal(replay, body) || inventory.All()[0].Count != 1 { t.Fatalf("auto replay code=%d err=%v", code, err) @@ -198,7 +204,11 @@ func TestEatFoodSQLitePersistsRecoveryAndSequenceReplay(t *testing.T) { if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() s, inventory, characters = foodTestService(t, repo) code, replayed, _, err := s.Handle("/EatFood", request) if err != nil || code != 22 || !bytes.Equal(body, replayed) { @@ -239,7 +249,9 @@ func TestEatFoodRejectsInvalidStacksAndContextWithoutMutation(t *testing.T) { t.Fatalf("invalid request %d accepted", i) } } - s.AttachContext(func() (int, error) { return 21, nil }, func() bool { return true }) + if err := s.AttachContext(func() (int, error) { return 21, nil }, func() bool { return true }); err != nil { + t.Fatal(err) + } if _, _, _, err = s.Handle("/EatFood", foodRequest(12, 77, 0, stacks[0])); err == nil { t.Fatal("accepted food during battle") } @@ -309,7 +321,9 @@ func TestCurrentHealthGrowthAndImmortalClearPersistedInjury(t *testing.T) { request = wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 77) target, _, _ := wire.Varint(request, 2) candidate, _ := characters.Find(target) - characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{candidate.ID: 42}, FullRestore: map[[2]uint64]bool{{42, candidate.TalentLevel}: true}}) + if err := characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{candidate.ID: 42}, FullRestore: map[[2]uint64]bool{{42, candidate.TalentLevel}: true}}); err != nil { + t.Fatal(err) + } if _, _, _, err = characters.Handle("/CharImmortal", request); err != nil { t.Fatal(err) } @@ -375,7 +389,11 @@ func TestCurrentHealthRetainsSavedCharacterHPWithoutSeparateEntryAcrossReopen(t if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() characters = open(repo) check(characters) if err = characters.SetCurrentHealth(77, 300); err != nil { @@ -392,7 +410,9 @@ func TestCurrentHealthRetainsSavedCharacterHPWithoutSeparateEntryAcrossReopen(t request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 78) target, _, _ := wire.Varint(request, 2) candidate, _ := characters.Find(target) - characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{candidate.ID: 42}, FullRestore: map[[2]uint64]bool{{42, candidate.TalentLevel}: true}}) + if err := characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{candidate.ID: 42}, FullRestore: map[[2]uint64]bool{{42, candidate.TalentLevel}: true}}); err != nil { + t.Fatal(err) + } if _, _, _, err = characters.Handle("/CharImmortal", request); err != nil { t.Fatal(err) } @@ -458,12 +478,18 @@ func TestEatFoodSQLiteRollbackRestoresInventoryHealthAndReplayLedger(t *testing. if err = op.Rollback(); !errors.Is(err, stateio.ErrStateRecoveryRequired) { t.Fatalf("expected recovery fencing, got %v", err) } - repo.Close() + if err := repo.Close(); err != nil { + t.Fatal(err) + } repo, err = accountstate.Open(path) if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() s, inventory, characters = foodTestService(t, repo) if inventory.All()[0].Count != 1 { t.Fatal("rollback lost inventory") diff --git a/go/internal/server/player/equipment.go b/go/internal/server/player/equipment.go index e4117f7..f2eab81 100644 --- a/go/internal/server/player/equipment.go +++ b/go/internal/server/player/equipment.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "math" "reflect" "sort" @@ -330,9 +331,7 @@ func (s *EquipmentInventory) grantLocked(identity string, entry Equipment) (Equi next := equipmentSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex + 1, Equipment: append([]Equipment(nil), s.owned.Equipment...), Granted: make(map[string]uint64, len(s.owned.Granted)+1)} next.Equipment = append(next.Equipment, entry) - for k, v := range s.owned.Granted { - next.Granted[k] = v - } + maps.Copy(next.Granted, s.owned.Granted) next.Granted[identity] = entry.InvenIndex if err := s.commitLocked(next, "grant"); err != nil { return Equipment{}, err @@ -541,7 +540,7 @@ func (s *EquipmentInventory) optionRerollRequest(request []byte) (int, []byte, b } if locked { lockedCount++ - } else if canReroll && !(rerollType == 1 && i == 0) { + } else if canReroll && !(rerollType == 1 && i == 0) { //nolint:staticcheck // QF1001 unlockedRerollable++ } } @@ -1767,12 +1766,11 @@ func validEquipmentMark(raw []byte) bool { return false } left := parts[0] - if strings.HasPrefix(left, "~") { - icon, err := strconv.Atoi(strings.TrimPrefix(left, "~")) + if suffix, found := strings.CutPrefix(left, "~"); found { + icon, err := strconv.Atoi(suffix) return err == nil && icon >= 1 && icon <= 15 } - if strings.HasPrefix(left, "_") { - text := strings.TrimPrefix(left, "_") + if text, found := strings.CutPrefix(left, "_"); found { return utf8.RuneCountInString(text) == 1 && len([]byte(text)) <= 2 } return len(left) >= 1 && len([]byte(left)) <= 2 @@ -1864,9 +1862,7 @@ func cloneEquipmentSnapshot(current equipmentSnapshot) equipmentSnapshot { for i := range next.Equipment { next.Equipment[i] = cloneEquipment(next.Equipment[i]) } - for key, value := range current.Granted { - next.Granted[key] = value - } + maps.Copy(next.Granted, current.Granted) return next } @@ -2044,9 +2040,7 @@ func (s *EquipmentInventory) use(request []byte) (int, []byte, bool, error) { defer s.mu.Unlock() next := equipmentSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex, Equipment: append([]Equipment(nil), s.owned.Equipment...), Granted: make(map[string]uint64, len(s.owned.Granted))} - for k, v := range s.owned.Granted { - next.Granted[k] = v - } + maps.Copy(next.Granted, s.owned.Granted) position := -1 for i, current := range next.Equipment { if current.InvenIndex == equipmentIndex { diff --git a/go/internal/server/player/equipment_batch_preset.go b/go/internal/server/player/equipment_batch_preset.go index 7623e07..9c8a001 100644 --- a/go/internal/server/player/equipment_batch_preset.go +++ b/go/internal/server/player/equipment_batch_preset.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "sort" "strconv" "strings" @@ -124,7 +125,7 @@ func (s *EquipmentInventory) batchUse(request []byte) (int, []byte, bool, error) for index := range affected { characterIndices = append(characterIndices, index) } - sort.Slice(characterIndices, func(i, j int) bool { return characterIndices[i] < characterIndices[j] }) + slices.Sort(characterIndices) var response []byte for _, index := range characterIndices { if character, found := s.characters.Find(index); found { diff --git a/go/internal/server/player/equipment_batch_use_test.go b/go/internal/server/player/equipment_batch_use_test.go index 10d816e..bd60a76 100644 --- a/go/internal/server/player/equipment_batch_use_test.go +++ b/go/internal/server/player/equipment_batch_use_test.go @@ -3,7 +3,7 @@ package player import ( "path/filepath" "reflect" - "sort" + "slices" "testing" "bd2server/internal/server/wire" @@ -247,7 +247,7 @@ func responseCharacterIndices(t *testing.T, response []byte) []uint64 { }); err != nil { t.Fatal(err) } - sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) + slices.Sort(result) return result } diff --git a/go/internal/server/player/equipment_craft.go b/go/internal/server/player/equipment_craft.go index adda030..21860bd 100644 --- a/go/internal/server/player/equipment_craft.go +++ b/go/internal/server/player/equipment_craft.go @@ -108,7 +108,7 @@ func (s *EquipmentInventory) prepareEquipmentMaking(characterIndex, recipeID, co } resultCount := count * recipe.ResultCount generated := make([]Equipment, 0, resultCount) - for i := uint64(0); i < resultCount; i++ { + for range resultCount { rolled, err := s.craft.Generate(recipeID) if err != nil { return nil, 0, 0, 0, fmt.Errorf("player: roll equipment making result: %w", err) @@ -383,10 +383,7 @@ func (s *EquipmentInventory) runUpgradeToBreak(indices []uint64, target uint64, position := s.equipmentPositionLocked(index) entry := s.owned.Equipment[position] maximum := s.upgrade.MaxLevel[entry.ID] - goal := target - if goal > maximum { - goal = maximum - } + goal := min(target, maximum) for !stopUpgrades && entry.Level < goal { level, _, err := s.upgrade.Level(entry.ID, entry.Level) if err != nil { diff --git a/go/internal/server/player/equipment_preset_test.go b/go/internal/server/player/equipment_preset_test.go index e3b3226..3fb3d76 100644 --- a/go/internal/server/player/equipment_preset_test.go +++ b/go/internal/server/player/equipment_preset_test.go @@ -96,7 +96,7 @@ func TestEquipmentPresetRejectsForgedEquipment(t *testing.T) { request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 100), 3, 1) request = wire.AppendString(request, 4, "Forged") request = wire.AppendVarint(request, 5, 1) - for slot := 0; slot < equipmentSlotCount; slot++ { + for slot := range equipmentSlotCount { entry := wire.AppendVarint(nil, 1, uint64(slot)) if slot == 1 { entry = wire.AppendVarint(entry, 2, item.InvenIndex) diff --git a/go/internal/server/player/equipment_reconnect_test.go b/go/internal/server/player/equipment_reconnect_test.go index 4e72eea..a0c63e4 100644 --- a/go/internal/server/player/equipment_reconnect_test.go +++ b/go/internal/server/player/equipment_reconnect_test.go @@ -26,7 +26,9 @@ func TestEquipmentReconnectRestoresEveryCharacterAndClearedReplacement(t *testin if e != nil { t.Fatal(e) } - chars.AttachMaxHealth(func(Character) (uint64, error) { return 100, nil }) + if err := chars.AttachMaxHealth(func(Character) (uint64, error) { return 100, nil }); err != nil { + t.Fatal(err) + } if e = chars.EnsurePersisted(); e != nil { t.Fatal(e) } @@ -78,7 +80,11 @@ func TestEquipmentReconnectRestoresEveryCharacterAndClearedReplacement(t *testin if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() eq = open(repo) eq.BeginSession("after-restart") code, response, handled, err := eq.Handle("/EquipInfo", wire.AppendVarint(nil, 1, 1)) diff --git a/go/internal/server/player/equipment_test.go b/go/internal/server/player/equipment_test.go index 42869ee..c059041 100644 --- a/go/internal/server/player/equipment_test.go +++ b/go/internal/server/player/equipment_test.go @@ -279,7 +279,7 @@ func smeltingTestDesign(ratios [][]float64) *gamedata.EquipmentSmeltingDesign { Mileage: gamedata.EquipmentSmeltingMileage{UseType: 8, UseID: 10, UseCount: 1000, RewardType: 68, RewardCount: 1}, MaxStreak: 5000, } - for i := 0; i < 3; i++ { + for i := range 3 { design.Ranks[[2]uint64{904, uint64(i + 1)}] = gamedata.EquipmentSmeltingRank{Values: []uint64{1, 2, 3, 4}, GrowthPoint: []uint64{1, 2, 3, 4}, Ratio: ratios[i]} } return design diff --git a/go/internal/server/player/friendship.go b/go/internal/server/player/friendship.go index f8cecb7..b797fd4 100644 --- a/go/internal/server/player/friendship.go +++ b/go/internal/server/player/friendship.go @@ -6,7 +6,7 @@ import ( "errors" "fmt" "math" - "sort" + "slices" "strconv" "sync" "time" @@ -192,7 +192,7 @@ func (s *FriendshipService) info(entries map[string]FriendshipEntry) []byte { ids = append(ids, id) } } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) var body []byte for _, id := range ids { state := FriendshipState{CostumeID: id, Level: 1} @@ -303,13 +303,7 @@ func (s *FriendshipService) counsel(request []byte, state FriendshipState, max u if err != nil { return nil, err } - completed := false - for _, id := range state.Sessions { - if id == session { - completed = true - break - } - } + completed := slices.Contains(state.Sessions, session) if !free { rewards = append(rewards, s.design.Default.CounselingRewards...) daily.Used++ @@ -331,7 +325,7 @@ func (s *FriendshipService) counsel(request []byte, state FriendshipState, max u if quick == 0 { if !completed { next.Sessions = append(next.Sessions, session) - sort.Slice(next.Sessions, func(i, j int) bool { return next.Sessions[i] < next.Sessions[j] }) + slices.Sort(next.Sessions) } } bundle, err := s.grant(key, rewards) @@ -395,7 +389,7 @@ func (s *FriendshipService) grant(identity string, rewards []gamedata.Reward) ([ if reward.ID == 0 || reward.ID > math.MaxInt32 { return nil, errors.New("player: invalid friendship item reward") } - items = append(items, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) + items = append(items, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) //nolint:staticcheck // S1016 } } if _, err := s.wallet.GrantQuestOnce(identity+":currency", rewards); err != nil { diff --git a/go/internal/server/player/friendship_sqlite_test.go b/go/internal/server/player/friendship_sqlite_test.go index 12c460f..ecb8180 100644 --- a/go/internal/server/player/friendship_sqlite_test.go +++ b/go/internal/server/player/friendship_sqlite_test.go @@ -55,7 +55,8 @@ func TestFriendshipSQLiteAtomicFailureRestartAndExactRetry(t *testing.T) { t.Fatal(err) } store := &friendshipFailStore{AtomicEntryStore: repo} - s, inventory, wallet := sqliteFriendshipService(t, store, template) + var wallet *Wallet + s, inventory, _ := sqliteFriendshipService(t, store, template) items, err := inventory.GrantOnce("seed", []gamedata.BattleReward{{Type: 8, ID: 7, Count: 3}}) if err != nil { t.Fatal(err) @@ -91,7 +92,11 @@ func TestFriendshipSQLiteAtomicFailureRestartAndExactRetry(t *testing.T) { if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() s, inventory, wallet = sqliteFriendshipService(t, repo, template) if rollback { if wallet.Snapshot().FreeJewelry != 0 || len(s.collection.FriendshipEntries()) != 0 { diff --git a/go/internal/server/player/growth.go b/go/internal/server/player/growth.go index 93cbb23..58ced3d 100644 --- a/go/internal/server/player/growth.go +++ b/go/internal/server/player/growth.go @@ -401,7 +401,7 @@ func (s *CharacterStore) promoteCharacter(current Character, position int, fromC requested := make(map[[2]uint64]uint64) var gold uint64 for _, material := range materials { - if material.Type == 4 { + if material.Type == 4 { //nolint:staticcheck // QF1003 if material.InvenIndex != 0 || material.ID != 0 || gold != 0 { return 0, nil, true, errors.New("player: invalid promotion currency") } diff --git a/go/internal/server/player/growth_test.go b/go/internal/server/player/growth_test.go index 0cffed5..ab7170b 100644 --- a/go/internal/server/player/growth_test.go +++ b/go/internal/server/player/growth_test.go @@ -27,8 +27,12 @@ func TestCharImmortalReturnsFullOwnedSnapshot(t *testing.T) { if err != nil { t.Fatal(err) } - characters.SetCurrentHealth(owned[0].InvenIndex, 0) - characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{6010: 42}, FullRestore: map[[2]uint64]bool{{42, 1}: true}}) + if err := characters.SetCurrentHealth(owned[0].InvenIndex, 0); err != nil { + t.Fatal(err) + } + if err := characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{6010: 42}, FullRestore: map[[2]uint64]bool{{42, 1}: true}}); err != nil { + t.Fatal(err) + } packed := binary.AppendUvarint(nil, owned[0].InvenIndex) request := wire.AppendBytes(wire.AppendVarint(nil, 1, 23), 2, packed) code, response, ok, err := characters.Handle("/CharImmortal", request) @@ -169,8 +173,12 @@ func TestGrowthAndImmortalShareDynamicMaximumHealth(t *testing.T) { if hp, _, _ := wire.Varint(grown, 3); hp != 513 { t.Fatalf("grown HP=%d want 513", hp) } - characters.SetCurrentHealth(77, 0) - characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{350: 42}, FullRestore: map[[2]uint64]bool{{42, 0}: true}}) + if err := characters.SetCurrentHealth(77, 0); err != nil { + t.Fatal(err) + } + if err := characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{350: 42}, FullRestore: map[[2]uint64]bool{{42, 0}: true}}); err != nil { + t.Fatal(err) + } immortal := wire.AppendVarint(wire.AppendVarint(nil, 1, 12), 2, 77) _, response, _, err = characters.Handle("/CharImmortal", immortal) if err != nil { diff --git a/go/internal/server/player/immortal_design_test.go b/go/internal/server/player/immortal_design_test.go index e286fd6..a705124 100644 --- a/go/internal/server/player/immortal_design_test.go +++ b/go/internal/server/player/immortal_design_test.go @@ -19,7 +19,9 @@ func TestImmortalRequiresDesignedDeadTalentAndReplaysSameSequence(t *testing.T) t.Fatal(err) } s.maxHealth = func(Character) (uint64, error) { return 700, nil } - s.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{999: 88}, FullRestore: map[[2]uint64]bool{{88, 3}: true}}) + if err := s.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{999: 88}, FullRestore: map[[2]uint64]bool{{88, 3}: true}}); err != nil { + t.Fatal(err) + } s.BeginSession("a") req := wire.AppendVarint(wire.AppendVarint(nil, 1, 12), 2, 77) _, body, _, err := s.Handle("/CharImmortal", req) @@ -33,8 +35,12 @@ func TestImmortalRequiresDesignedDeadTalentAndReplaysSameSequence(t *testing.T) if _, _, _, err := s.Handle("/CharImmortal", wire.AppendVarint(wire.AppendVarint(nil, 1, 13), 2, 77)); err == nil { t.Fatal("alive new request restored") } - s.SetCurrentHealth(77, 0) - s.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{999: 88}, FullRestore: map[[2]uint64]bool{{88, 2}: true}}) + if err := s.SetCurrentHealth(77, 0); err != nil { + t.Fatal(err) + } + if err := s.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{999: 88}, FullRestore: map[[2]uint64]bool{{88, 2}: true}}); err != nil { + t.Fatal(err) + } if _, _, _, err := s.Handle("/CharImmortal", wire.AppendVarint(wire.AppendVarint(nil, 1, 14), 2, 77)); err == nil { t.Fatal("wrong talent level revived") } diff --git a/go/internal/server/player/inventory_slots.go b/go/internal/server/player/inventory_slots.go index df62838..68a8b3e 100644 --- a/go/internal/server/player/inventory_slots.go +++ b/go/internal/server/player/inventory_slots.go @@ -245,15 +245,12 @@ func inventorySlotPrice(rule gamedata.InventorySlotRule, current, count uint64) } var total uint64 step := rule.BasePrice / 3 - for i := uint64(0); i < count; i++ { + for i := range count { offset := current + i + 1 - rule.Default if step != 0 && offset > (math.MaxUint64-rule.BasePrice)/step { return 0, errors.New("player: inventory slot price overflow") } - price := rule.BasePrice + step*offset - if price > rule.MaxPrice { - price = rule.MaxPrice - } + price := min(rule.BasePrice+step*offset, rule.MaxPrice) if math.MaxUint64-total < price { return 0, errors.New("player: inventory slot total price overflow") } diff --git a/go/internal/server/player/item_craft_test.go b/go/internal/server/player/item_craft_test.go index f6cc7f5..c8f5841 100644 --- a/go/internal/server/player/item_craft_test.go +++ b/go/internal/server/player/item_craft_test.go @@ -9,7 +9,7 @@ import ( "errors" "os" "path/filepath" - "sort" + "slices" "testing" ) @@ -70,7 +70,7 @@ func TestItemCraftAgainstInstalledGameData(t *testing.T) { producers = append(producers, id) } } - sort.Slice(producers, func(i, j int) bool { return producers[i] < producers[j] }) + slices.Sort(producers) if len(producers) == 0 { t.Fatal("missing actual producers") } @@ -100,14 +100,14 @@ func TestItemCraftAgainstInstalledGameData(t *testing.T) { ids = append(ids, rid) } } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) if len(ids) == 0 { t.Fatal("missing level1 recipe") } r := recipes[ids[0]] var materials []gamedata.BattleReward for _, c := range r.Costs { - materials = append(materials, gamedata.BattleReward{Type: c.Type, ID: c.ID, Count: c.Count}) + materials = append(materials, gamedata.BattleReward{Type: c.Type, ID: c.ID, Count: c.Count}) //nolint:staticcheck // S1016 } if _, e = items.GrantOnce("materials", materials); e != nil { t.Fatal(e) @@ -229,7 +229,11 @@ func TestAlchemyBatchSQLiteCanExceedOrdinaryCraftLimit(t *testing.T) { if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() s, items, chars, wallet := craftFixture(t, repo, 8) rule := s.talents.Rules[[2]uint64{42, 2}] rule.Values[0] = 1 @@ -292,12 +296,18 @@ func TestItemCraftFinalReceiptFailureRollsBackEveryDomain(t *testing.T) { if e = op.Rollback(); e != nil && !errors.Is(e, stateio.ErrStateRecoveryRequired) { t.Fatal(e) } - repo.Close() + if err := repo.Close(); err != nil { + t.Fatal(err) + } repo, e = accountstate.Open(path) if e != nil { t.Fatal(e) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() next, items, chars, wallet := craftFixture(t, repo, 8) c, _ := chars.Find(77) if c.TalentExp != 0 || wallet.Snapshot().Catalyst != 100 || craftCount(items, 8, 1) != 200 || craftCount(items, 8, 2) != 2 { diff --git a/go/internal/server/player/owned.go b/go/internal/server/player/owned.go index 4445bd0..e6c7920 100644 --- a/go/internal/server/player/owned.go +++ b/go/internal/server/player/owned.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "reflect" "sort" "strconv" @@ -291,9 +292,7 @@ func (s *Inventory) GrantOnce(identity string, rewards []gamedata.BattleReward) func cloneOwnedSnapshot(current ownedSnapshot) ownedSnapshot { next := ownedSnapshot{Version: current.Version, NextIndex: current.NextIndex, Items: append([]Item(nil), current.Items...), Granted: make(map[string]bool, len(current.Granted)+1), GrantItems: make(map[string][]uint64, len(current.GrantItems)+1)} - for k, v := range current.Granted { - next.Granted[k] = v - } + maps.Copy(next.Granted, current.Granted) for k, v := range current.GrantItems { next.GrantItems[k] = append([]uint64(nil), v...) } @@ -432,9 +431,7 @@ func (s *Inventory) ConsumeAndRefund(requested []Item, refunds []gamedata.Growth defer s.mu.Unlock() next := ownedSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex, Items: append([]Item(nil), s.owned.Items...), Granted: make(map[string]bool, len(s.owned.Granted)), GrantItems: make(map[string][]uint64, len(s.owned.GrantItems))} - for k, v := range s.owned.Granted { - next.Granted[k] = v - } + maps.Copy(next.Granted, s.owned.Granted) for k, v := range s.owned.GrantItems { next.GrantItems[k] = append([]uint64(nil), v...) } diff --git a/go/internal/server/player/preset_runtime.go b/go/internal/server/player/preset_runtime.go index 2da202c..014854a 100644 --- a/go/internal/server/player/preset_runtime.go +++ b/go/internal/server/player/preset_runtime.go @@ -3,6 +3,7 @@ package player import ( "errors" "fmt" + "slices" "sort" "bd2server/internal/server/wire" @@ -229,7 +230,7 @@ func (s *CharacterStore) ApplyPresetCostumes(assignments map[uint64]uint64) ([]C for index := range assignments { indices = append(indices, index) } - sort.Slice(indices, func(i, j int) bool { return indices[i] < indices[j] }) + slices.Sort(indices) result := make([]Character, 0, len(indices)) for _, index := range indices { character, found := s.Find(index) diff --git a/go/internal/server/player/profile.go b/go/internal/server/player/profile.go index a51a5e1..d447233 100644 --- a/go/internal/server/player/profile.go +++ b/go/internal/server/player/profile.go @@ -48,7 +48,7 @@ func validMasterTitleName(name string, fromRequest bool) bool { } if fromRequest { lower := unicode.ToLower(r) - if !((lower >= '0' && lower <= '9') || (lower >= 'a' && lower <= 'z') || (r >= 0xAC00 && r <= 0xD7A3) || (r >= 0x4E00 && r <= 0x9FD5) || (r >= 0x3041 && r <= 0x30FE) || (r >= 0x0180 && r <= 0x024F)) { + if !((lower >= '0' && lower <= '9') || (lower >= 'a' && lower <= 'z') || (r >= 0xAC00 && r <= 0xD7A3) || (r >= 0x4E00 && r <= 0x9FD5) || (r >= 0x3041 && r <= 0x30FE) || (r >= 0x0180 && r <= 0x024F)) { //nolint:staticcheck // QF1001 return false } } diff --git a/go/internal/server/player/recruit.go b/go/internal/server/player/recruit.go index 6ef3ad9..2be420d 100644 --- a/go/internal/server/player/recruit.go +++ b/go/internal/server/player/recruit.go @@ -11,7 +11,7 @@ import ( "fmt" "math" "math/big" - "sort" + "slices" "strconv" "sync" "time" @@ -269,7 +269,7 @@ func (s *RecruitService) rollSpecial(count uint64) (specialRecruitState, error) } } } - sort.Slice(pool, func(i, j int) bool { return pool[i] < pool[j] }) + slices.Sort(pool) for len(state.IDs) < int(s.design.AppearCount) && len(pool) > 0 { var total uint64 for _, id := range pool { @@ -321,7 +321,7 @@ func (s *RecruitService) scoutInfo(state specialRecruitState) []byte { ids = append(ids, id) } } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) for _, id := range ids { b = wire.AppendVarint(b, 4, id) } diff --git a/go/internal/server/player/recruit_test.go b/go/internal/server/player/recruit_test.go index dc58012..870feed 100644 --- a/go/internal/server/player/recruit_test.go +++ b/go/internal/server/player/recruit_test.go @@ -78,7 +78,11 @@ func TestRecruitSQLiteFailureAfterConsumeRollsBackAllDomains(t *testing.T) { if e != nil { t.Fatal(e) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() inventory, e = OpenInventory(repo, starter) if e != nil { t.Fatal(e) @@ -267,7 +271,7 @@ func TestRecruitInvalidSpecialRequestsDoNotPersistRotation(t *testing.T) { if _, exists := s.collection.Grant("special-recruit-state"); exists { t.Fatal("invalid buy wrote rotation") } - s.wallet.state.Currency.FreeJewelry = 0 + s.wallet.state.Currency.FreeJewelry = 0 //nolint:staticcheck // QF1008 if _, _, _, e := s.Handle("/CharSpecialScoutReset", wire.AppendVarint(nil, 1, 2)); e == nil { t.Fatal("unaffordable reset accepted") } diff --git a/go/internal/server/player/talent_dispatch.go b/go/internal/server/player/talent_dispatch.go index 0454c48..74fbeee 100644 --- a/go/internal/server/player/talent_dispatch.go +++ b/go/internal/server/player/talent_dispatch.go @@ -11,7 +11,7 @@ import ( "fmt" "math" "math/big" - "sort" + "slices" "sync" "time" ) @@ -179,7 +179,7 @@ func (s *TalentDispatchService) Handle(path string, req []byte) (int, []byte, bo ids = append(ids, id) } } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) for _, id := range ids { b = wire.AppendBytes(b, 1, s.rowWire(st.Rows[id])) } diff --git a/go/internal/server/player/talent_upgrade_test.go b/go/internal/server/player/talent_upgrade_test.go index 60ef76c..a36d8a4 100644 --- a/go/internal/server/player/talent_upgrade_test.go +++ b/go/internal/server/player/talent_upgrade_test.go @@ -312,7 +312,11 @@ func TestTalentSkillUpgradeLedgerSurvivesSQLiteCloseAndReopen(t *testing.T) { if err != nil { t.Fatal(err) } - defer repository.Close() + defer func() { + if err := repository.Close(); err != nil { + t.Error(err) + } + }() inventory, err = OpenInventory(repository, &Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) diff --git a/go/internal/server/player/talent_use.go b/go/internal/server/player/talent_use.go index f5910f0..12f148d 100644 --- a/go/internal/server/player/talent_use.go +++ b/go/internal/server/player/talent_use.go @@ -12,6 +12,7 @@ import ( "fmt" "math" "math/big" + "slices" "sort" "sync" "time" @@ -419,7 +420,7 @@ func (s *TalentUseService) PackInfo(pack int) ([]byte, error) { for g := range v.Skills { groups = append(groups, g) } - sort.Slice(groups, func(i, j int) bool { return groups[i] < groups[j] }) + slices.Sort(groups) var b []byte for _, g := range groups { state := v.Skills[g] diff --git a/go/internal/server/player/talent_use_test.go b/go/internal/server/player/talent_use_test.go index cf9cb54..876f531 100644 --- a/go/internal/server/player/talent_use_test.go +++ b/go/internal/server/player/talent_use_test.go @@ -22,7 +22,7 @@ func (e *talentEconomyTest) Apply(identity string, _ []gamedata.Reward, r []game e.calls++ var rewards []gamedata.BattleReward for _, v := range r { - rewards = append(rewards, gamedata.BattleReward{Type: v.Type, ID: v.ID, Count: v.Count}) + rewards = append(rewards, gamedata.BattleReward{Type: v.Type, ID: v.ID, Count: v.Count}) //nolint:staticcheck // S1016 } items, err := e.inventory.GrantOnce(identity, rewards) if err != nil { @@ -44,7 +44,9 @@ func talentTest(t *testing.T, store stateio.Store, class uint64) (*TalentUseServ if err != nil { t.Fatal(err) } - chars.AttachMaxHealth(func(Character) (uint64, error) { return 100, nil }) + if err := chars.AttachMaxHealth(func(Character) (uint64, error) { return 100, nil }); err != nil { + t.Fatal(err) + } if err = chars.EnsurePersisted(); err != nil { t.Fatal(err) } @@ -163,12 +165,18 @@ func TestTalentReceiptFailureRollsBackRewardCostAndExperience(t *testing.T) { if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { t.Fatal(err) } - repo.Close() + if err := repo.Close(); err != nil { + t.Fatal(err) + } repo, err = accountstate.Open(path) if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() next, chars, wallet, e := talentTest(t, repo, 1) c, _ := chars.Find(77) if c.TalentExp != 0 || wallet.Snapshot().Catalyst != 50 || len(e.inventory.All()) != 0 { @@ -345,12 +353,18 @@ func TestCharmSQLiteRestartAndNewCharmRefreshDamagedHealth(t *testing.T) { if err = chars.SetCurrentHealth(index, 5); err != nil { t.Fatal(err) } - repo.Close() + if err := repo.Close(); err != nil { + t.Fatal(err) + } repo, err = accountstate.Open(path) if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() next, chars, _, _ := talentTest(t, repo, 19) next.design = s.design next.now = time.Now diff --git a/go/internal/server/player/wallet.go b/go/internal/server/player/wallet.go index ecb939a..1387c76 100644 --- a/go/internal/server/player/wallet.go +++ b/go/internal/server/player/wallet.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "math" "sync" @@ -349,12 +350,8 @@ func (s *Wallet) GrantQuestOnce(identity string, rewards []gamedata.Reward) (Cur func cloneWallet(in walletSnapshot) walletSnapshot { out := walletSnapshot{Version: in.Version, Currency: in.Currency, Granted: make(map[string]bool, len(in.Granted)), Spent: make(map[string]bool, len(in.Spent))} - for key, value := range in.Granted { - out.Granted[key] = value - } - for key, value := range in.Spent { - out.Spent[key] = value - } + maps.Copy(out.Granted, in.Granted) + maps.Copy(out.Spent, in.Spent) return out } diff --git a/go/internal/server/progress/chests.go b/go/internal/server/progress/chests.go index 19956b1..a6a7544 100644 --- a/go/internal/server/progress/chests.go +++ b/go/internal/server/progress/chests.go @@ -63,8 +63,8 @@ func (s *Store) OpenedFieldRewards(pack int) ([]int, error) { if len(raw) == 0 { return nil, fmt.Errorf("progress: invalid field reward entry") } - if strings.HasPrefix(key, prefix) { - id, e := strconv.Atoi(strings.TrimPrefix(key, prefix)) + if suffix, found := strings.CutPrefix(key, prefix); found { + id, e := strconv.Atoi(suffix) if e != nil || id <= 0 { return nil, fmt.Errorf("progress: invalid field reward key") } diff --git a/go/internal/server/progress/field_buffs.go b/go/internal/server/progress/field_buffs.go index 1d801f6..14475ac 100644 --- a/go/internal/server/progress/field_buffs.go +++ b/go/internal/server/progress/field_buffs.go @@ -3,7 +3,7 @@ package progress import ( "bd2server/internal/server/wire" "fmt" - "sort" + "slices" "strconv" ) @@ -41,7 +41,7 @@ func (s *Store) FieldBuffs() ([][]byte, error) { } ids = append(ids, id) } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + slices.Sort(ids) var out [][]byte for _, id := range ids { out = append(out, entries[strconv.FormatUint(id, 10)]) diff --git a/go/internal/server/progress/progress.go b/go/internal/server/progress/progress.go index ee07333..090455a 100644 --- a/go/internal/server/progress/progress.go +++ b/go/internal/server/progress/progress.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "sort" "strconv" "strings" @@ -255,9 +256,7 @@ func (s *Store) UpdateQuest(request []byte) (int, error) { s.mu.Lock() defer s.mu.Unlock() quests := make(map[string]QuestProgress, len(s.quests)+1) - for key, current := range s.quests { - quests[key] = current - } + maps.Copy(quests, s.quests) selection := s.selections[strconv.Itoa(progress.PackID)] progress.Difficulty = selection.Difficulty if _, acceptedNormal := s.quests[questKey(progress.PackID, progress.QuestID)]; acceptedNormal && selection.QuestID != progress.QuestID { @@ -454,9 +453,7 @@ func (s *Store) SelectQuest(packID int, selection QuestSelection) error { defer s.mu.Unlock() old := s.selections next := make(map[string]QuestSelection, len(old)+1) - for key, value := range old { - next[key] = value - } + maps.Copy(next, old) next[strconv.Itoa(packID)] = selection s.selections = next if err := s.commit(s.position, s.tutorials, s.quests, s.cleared); err != nil { @@ -490,9 +487,7 @@ func (s *Store) AcceptQuest(questID, packID, difficulty int) error { s.mu.Lock() defer s.mu.Unlock() quests := make(map[string]QuestProgress, len(s.quests)+1) - for key, value := range s.quests { - quests[key] = value - } + maps.Copy(quests, s.quests) key := questKey(packID, questID, difficulty) if _, exists := quests[key]; !exists { quests[key] = QuestProgress{QuestID: questID, PackID: packID, Difficulty: difficulty} diff --git a/go/internal/server/progress/research.go b/go/internal/server/progress/research.go index f07ad5c..4cf498f 100644 --- a/go/internal/server/progress/research.go +++ b/go/internal/server/progress/research.go @@ -24,8 +24,8 @@ func (s *Store) ResearchObjects(pack int) ([]int, error) { if string(raw) != "once" { return nil, fmt.Errorf("progress: invalid research entry") } - if strings.HasPrefix(key, prefix) { - id, e := strconv.Atoi(strings.TrimPrefix(key, prefix)) + if suffix, found := strings.CutPrefix(key, prefix); found { + id, e := strconv.Atoi(suffix) if e != nil || id <= 0 { return nil, fmt.Errorf("progress: invalid research identity") } diff --git a/go/internal/server/resourcefetch/fetch.go b/go/internal/server/resourcefetch/fetch.go index b3c44d2..e5dc558 100644 --- a/go/internal/server/resourcefetch/fetch.go +++ b/go/internal/server/resourcefetch/fetch.go @@ -203,7 +203,7 @@ func downloadBytes(ctx context.Context, client *http.Client, rawURL string, limi if err != nil { return nil, fmt.Errorf("resource fetch: GET %s: %w", rawURL, err) } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() if response.StatusCode != http.StatusOK { return nil, fmt.Errorf("resource fetch: GET %s: HTTP %s", rawURL, response.Status) } @@ -230,7 +230,7 @@ func downloadFile(ctx context.Context, client *http.Client, rawURL, target strin if err != nil { return 0, err } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() if response.StatusCode != http.StatusOK { return 0, fmt.Errorf("HTTP %s", response.Status) } @@ -239,7 +239,7 @@ func downloadFile(ctx context.Context, client *http.Client, rawURL, target strin return 0, err } temporaryPath := temporary.Name() - defer os.Remove(temporaryPath) + defer func() { _ = os.Remove(temporaryPath) }() written, copyErr := io.Copy(temporary, response.Body) syncErr := temporary.Sync() closeErr := temporary.Close() @@ -270,17 +270,17 @@ func atomicWrite(target string, data []byte, mode os.FileMode) error { return err } temporaryPath := temporary.Name() - defer os.Remove(temporaryPath) + defer func() { _ = os.Remove(temporaryPath) }() if err := temporary.Chmod(mode); err != nil { - temporary.Close() + _ = temporary.Close() return err } if _, err := temporary.Write(data); err != nil { - temporary.Close() + _ = temporary.Close() return err } if err := temporary.Sync(); err != nil { - temporary.Close() + _ = temporary.Close() return err } if err := temporary.Close(); err != nil { @@ -317,7 +317,7 @@ func validUnityBundle(path string) (int64, bool) { if err != nil { return 0, false } - defer file.Close() + defer func() { _ = file.Close() }() var magic [7]byte if _, err := io.ReadFull(file, magic[:]); err != nil || string(magic[:]) != "UnityFS" { return 0, false diff --git a/go/internal/server/session/achievement_notify_test.go b/go/internal/server/session/achievement_notify_test.go index 3800974..f75cd46 100644 --- a/go/internal/server/session/achievement_notify_test.go +++ b/go/internal/server/session/achievement_notify_test.go @@ -24,11 +24,13 @@ func TestAchievementUpdateEmitsAbsoluteNotificationWithoutChangingEmptyResponse( t.Fatal(err) } server, _ := NewServer(fakeLogin{}, counter) - server.AttachResponseObserver(observer) + if err := server.AttachResponseObserver(observer); err != nil { + t.Fatal(err) + } logged := login(t, server) request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 987), 3, 1) body, _ := cryptox.EncryptBase64Payload(request, server.KeyForTest()) - for attempt := 0; attempt < 2; attempt++ { + for attempt := range 2 { reply, err := server.DispatchRaw("/AchievementUpdate", []byte(body), "s="+logged.Cookie) if err != nil { t.Fatal(err) diff --git a/go/internal/server/session/attendance_transaction_test.go b/go/internal/server/session/attendance_transaction_test.go index 6b57130..57938b5 100644 --- a/go/internal/server/session/attendance_transaction_test.go +++ b/go/internal/server/session/attendance_transaction_test.go @@ -38,7 +38,7 @@ func (e *attendanceTransactionEconomy) Apply(identity string, costs, rewards []g var items []gamedata.BattleReward for _, r := range rewards { if r.Type == 9 || r.Type == 8 { - items = append(items, gamedata.BattleReward{Type: r.Type, ID: r.ID, Count: r.Count}) + items = append(items, gamedata.BattleReward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 } } if _, err := e.inventory.GrantOnce(identity, items); err != nil { @@ -224,7 +224,7 @@ func TestAttendanceBatchRollsBackAndSuccessfulRetryGrantsOnce(t *testing.T) { if err := repo.Close(); err != nil { t.Fatal(err) } - repo, wallet, inv, svc, mailbox, eco = open() + repo, wallet, inv, svc, mailbox, _ = open() if !reflect.DeepEqual(mailState(repo), issuedMail) { t.Fatal("mail claim/history persisted after failed batch") } @@ -246,8 +246,12 @@ func TestAttendanceBatchRollsBackAndSuccessfulRetryGrantsOnce(t *testing.T) { if err := repo.Close(); err != nil { t.Fatal(err) } - repo, wallet, inv, svc, mailbox, eco = open() - defer repo.Close() + repo, wallet, inv, svc, mailbox, _ = open() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() items := inv.All() if wallet.Snapshot().Gold != 112 || len(items) != 1 || items[0].Type != 9 || items[0].ID != 987 || items[0].Count != 1 { t.Fatalf("successful attendance not persisted exactly once: wallet=%+v items=%+v", wallet.Snapshot(), items) diff --git a/go/internal/server/session/level_reward_transaction_test.go b/go/internal/server/session/level_reward_transaction_test.go index aa8337f..c7ee86e 100644 --- a/go/internal/server/session/level_reward_transaction_test.go +++ b/go/internal/server/session/level_reward_transaction_test.go @@ -21,7 +21,11 @@ func TestLevelRewardBatchRollsBackWalletInventoryAndClaimTogether(t *testing.T) if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() inv, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) @@ -73,7 +77,11 @@ func TestLevelRewardBatchRollsBackWalletInventoryAndClaimTogether(t *testing.T) if err != nil { t.Fatal(err) } - defer reopened.Close() + defer func() { + if err := reopened.Close(); err != nil { + t.Error(err) + } + }() inv, err = player.OpenInventory(reopened, &player.Starter{Version: "2.35.10"}) if err != nil { t.Fatal(err) diff --git a/go/internal/server/session/observer_test.go b/go/internal/server/session/observer_test.go index 3865429..f0d90b2 100644 --- a/go/internal/server/session/observer_test.go +++ b/go/internal/server/session/observer_test.go @@ -42,11 +42,19 @@ func TestObserverNotificationsAndFailuresShareRequestTransaction(t *testing.T) { if err != nil { t.Fatal(err) } - defer repository.Close() + defer func() { + if err := repository.Close(); err != nil { + t.Error(err) + } + }() server, _ := NewServer(fakeLogin{}, fakeDomain{}, &mutatingDomain{store: repository}) - server.AttachStateStore(repository) + if err := server.AttachStateStore(repository); err != nil { + t.Fatal(err) + } observer := &testProgressObserver{store: repository} - server.AttachResponseObserver(observer) + if err := server.AttachResponseObserver(observer); err != nil { + t.Fatal(err) + } logged := login(t, server) requests := []protocol.BatchRequest{ {Path: "/EmptyInfo", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 2))}, @@ -79,7 +87,11 @@ func TestObserverNotificationsAndFailuresShareRequestTransaction(t *testing.T) { if err != nil { t.Fatal(err) } - defer verified.Close() + defer func() { + if err := verified.Close(); err != nil { + t.Error(err) + } + }() var value []byte err = verified.QueryRow("SELECT payload FROM domain_entry WHERE domain_name='missions' AND bucket='test_observation' AND entry_key='/MutateTwoFiles'").Scan(&value) if err != nil || len(value) != 1 || value[0] != 3 { diff --git a/go/internal/server/session/server.go b/go/internal/server/session/server.go index 7f63a77..3c4dce1 100644 --- a/go/internal/server/session/server.go +++ b/go/internal/server/session/server.go @@ -433,7 +433,7 @@ func parseSessionCookie(cookie string) (string, error) { } var token string seen := false - for _, value := range strings.Split(cookie, ";") { + for value := range strings.SplitSeq(cookie, ";") { name, candidate, found := strings.Cut(strings.TrimSpace(value), "=") if !found || name != "s" { continue @@ -451,7 +451,7 @@ func parseSessionCookie(cookie string) (string, error) { return "", errors.New("invalid game session cookie") } for _, char := range token[:48] { - if !(char >= '0' && char <= '9' || char >= 'a' && char <= 'f') { + if !(char >= '0' && char <= '9' || char >= 'a' && char <= 'f') { //nolint:staticcheck // QF1001 return "", errors.New("invalid game session cookie") } } diff --git a/go/internal/server/session/server_test.go b/go/internal/server/session/server_test.go index 0a42fb2..c1c20c0 100644 --- a/go/internal/server/session/server_test.go +++ b/go/internal/server/session/server_test.go @@ -105,7 +105,11 @@ func TestBatchUsesOneAccountTransaction(t *testing.T) { if err != nil { t.Fatal(err) } - defer repository.Close() + defer func() { + if err := repository.Close(); err != nil { + t.Error(err) + } + }() for name, content := range map[string]string{"wallet": "old-wallet", "items": "old-items"} { if err := repository.Save(name, []byte(content)); err != nil { t.Fatal(err) @@ -129,7 +133,11 @@ func TestBatchUsesOneAccountTransaction(t *testing.T) { if err != nil { t.Fatal(err) } - defer verified.Close() + defer func() { + if err := verified.Close(); err != nil { + t.Error(err) + } + }() for name, want := range map[string]string{"wallet": "old-wallet", "items": "old-items"} { var got []byte err := verified.QueryRow(`SELECT payload FROM domain_state WHERE name=?`, name).Scan(&got) @@ -452,7 +460,11 @@ func TestAuthenticatedRequestTransactionCommitsOrRollsBackAllFiles(t *testing.T) if err != nil { t.Fatal(err) } - defer repository.Close() + defer func() { + if err := repository.Close(); err != nil { + t.Error(err) + } + }() for name, content := range map[string]string{"wallet": "old-wallet", "items": "old-items"} { if err := repository.Save(name, []byte(content)); err != nil { t.Fatal(err) @@ -477,7 +489,9 @@ func TestAuthenticatedRequestTransactionCommitsOrRollsBackAllFiles(t *testing.T) t.Fatal(openErr) } err = reader.QueryRow(`SELECT payload FROM domain_state WHERE name=?`, name).Scan(&got) - reader.Close() + if err := reader.Close(); err != nil { + t.Error(err) + } } else { got, err = repository.Load(name) } diff --git a/go/internal/server/stateio/entry_snapshot_test.go b/go/internal/server/stateio/entry_snapshot_test.go index 91ead25..95fa318 100644 --- a/go/internal/server/stateio/entry_snapshot_test.go +++ b/go/internal/server/stateio/entry_snapshot_test.go @@ -14,7 +14,11 @@ func TestOptionalGameplaySnapshotsPreserveAccountInitializationOnRestart(t *test if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() domains := []string{"characters", "collection", "deck", "equipment", "items", "mail", "missions", "progress", "wallet"} for _, name := range domains { if err := repo.Save(name, []byte("{}")); err != nil { @@ -45,7 +49,11 @@ func TestOptionalGameplaySnapshotsPreserveAccountInitializationOnRestart(t *test if err != nil { t.Fatal(err) } - defer reopened.Close() + defer func() { + if err := reopened.Close(); err != nil { + t.Error(err) + } + }() if status, err := reopened.InitializationState(domains...); err != nil || status != accountstate.InitializationComplete { t.Fatalf("restart rejected optional gameplay state: %d %v", status, err) } diff --git a/go/internal/server/todayquest/todayquest.go b/go/internal/server/todayquest/todayquest.go index 656d963..64cb4dc 100644 --- a/go/internal/server/todayquest/todayquest.go +++ b/go/internal/server/todayquest/todayquest.go @@ -142,10 +142,7 @@ func (s *Service) load() (snapshot, error) { b := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", p, ids[j]))) return string(a[:]) < string(b[:]) }) - n := s.design.PostCount - if n > len(ids) { - n = len(ids) - } + n := min(s.design.PostCount, len(ids)) st.Offered = append(st.Offered, ids[:n]...) } sort.Ints(st.Offered) @@ -375,7 +372,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) if b, ok := st.Responses[q.ID]; ok && st.Cleared[q.ID] { return 18, b, true, nil } - if !active || (q.ConditionType == 2 || q.ConditionType == 9 || q.ConditionType == 18) && len(a.Objects) < q.ConditionCount || !(q.ConditionType == 2 || q.ConditionType == 9 || q.ConditionType == 18) && a.Value < q.ConditionCount { + if !active || (q.ConditionType == 2 || q.ConditionType == 9 || q.ConditionType == 18) && len(a.Objects) < q.ConditionCount || !(q.ConditionType == 2 || q.ConditionType == 9 || q.ConditionType == 18) && a.Value < q.ConditionCount { //nolint:staticcheck // QF1001 return 0, nil, true, fmt.Errorf("todayquest: incomplete quest") } if q.ReputationCompleteID != 0 && s.CompleteReputation == nil { diff --git a/go/internal/server/todayquest/todayquest_test.go b/go/internal/server/todayquest/todayquest_test.go index 03e960e..0380974 100644 --- a/go/internal/server/todayquest/todayquest_test.go +++ b/go/internal/server/todayquest/todayquest_test.go @@ -157,7 +157,11 @@ func TestPackInfoResetsExpiredSQLiteCommissionAtWeeklyBoundary(t *testing.T) { if err != nil { t.Fatal(err) } - defer reopened.Close() + defer func() { + if err := reopened.Close(); err != nil { + t.Error(err) + } + }() s.store = stateio.EntrySnapshotStore{Entries: reopened, Domain: "missions", Bucket: "gameplay"} now = now.Add(time.Second) // PackInGameInfo calls Info without an intervening TodayQuestInfo request. diff --git a/go/internal/server/transport/http.go b/go/internal/server/transport/http.go index 2fa954a..26f86a8 100644 --- a/go/internal/server/transport/http.go +++ b/go/internal/server/transport/http.go @@ -182,7 +182,7 @@ func (h HTTP) clientResources(w http.ResponseWriter, r *http.Request) { http.Error(w, "PUT required", http.StatusMethodNotAllowed) return } - defer r.Body.Close() + defer func() { _ = r.Body.Close() }() body, err := io.ReadAll(io.LimitReader(r.Body, 16<<10+1)) if err != nil || len(body) > 16<<10 { http.Error(w, "resource selection too large", http.StatusRequestEntityTooLarge) @@ -267,7 +267,7 @@ func (h HTTP) game(w http.ResponseWriter, r *http.Request) { http.Error(w, "no dispatcher", http.StatusServiceUnavailable) return } - defer r.Body.Close() + defer func() { _ = r.Body.Close() }() body, err := io.ReadAll(io.LimitReader(r.Body, 8<<20+1)) if err != nil || len(body) > 8<<20 { http.Error(w, "request too large", http.StatusRequestEntityTooLarge) diff --git a/go/internal/server/world/achievement_progress_batch_test.go b/go/internal/server/world/achievement_progress_batch_test.go index fa15308..579e86b 100644 --- a/go/internal/server/world/achievement_progress_batch_test.go +++ b/go/internal/server/world/achievement_progress_batch_test.go @@ -124,7 +124,11 @@ func BenchmarkAchievementProgressBoundary(b *testing.B) { if err != nil { b.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + b.Error(err) + } + }() store := &achievementQueryStore{Repository: repo} design := &gamedata.AchievementCounterDesign{Groups: map[int][]int{}, Conditions: map[int]gamedata.AchievementCondition{}} for id := 1; id <= 500; id++ { @@ -150,12 +154,12 @@ func BenchmarkAchievementProgressBoundary(b *testing.B) { store.lists, store.loads, store.saves = 0, 0, 0 b.ReportAllocs() b.ResetTimer() - for iteration := 0; iteration < b.N; iteration++ { + for iteration := range b.N { op, err := repo.BeginOperation() if err != nil { b.Fatal(err) } - for request := 0; request < 57; request++ { + for request := range 57 { if bulk { _, _, err = s.ApplyGameplayProgress(conditions, nil) } else { @@ -170,7 +174,7 @@ func BenchmarkAchievementProgressBoundary(b *testing.B) { b.Fatal(err) } var events []GameplayAchievementRecordedEvent - for id := 0; id < eventCount; id++ { + for id := range eventCount { events = append(events, GameplayAchievementRecordedEvent{Identity: fmt.Sprintf("%d/%d/%d", iteration, request, id), Type: 14, SubType: uint64(id + 1), Count: 1}) } if bulk { diff --git a/go/internal/server/world/achievements.go b/go/internal/server/world/achievements.go index 948f42b..d50722b 100644 --- a/go/internal/server/world/achievements.go +++ b/go/internal/server/world/achievements.go @@ -207,8 +207,8 @@ func (s *AchievementService) Handle(path string, request []byte) (int, []byte, b } prefix := s.session + "/" for replayKey := range entries { - if strings.HasPrefix(replayKey, prefix) { - old, err := strconv.ParseUint(strings.TrimPrefix(replayKey, prefix), 10, 64) + if suffix, found := strings.CutPrefix(replayKey, prefix); found { + old, err := strconv.ParseUint(suffix, 10, 64) if err != nil { return fail(err) } diff --git a/go/internal/server/world/achievements_test.go b/go/internal/server/world/achievements_test.go index 3447ade..ae6e4ca 100644 --- a/go/internal/server/world/achievements_test.go +++ b/go/internal/server/world/achievements_test.go @@ -37,7 +37,7 @@ func TestAchievementUpdatePersistsAndRetries(t *testing.T) { store := stateio.NewMemory() s := achievementTestService(t, store) req := achievementRequest(10, 7, 1) - for i := 0; i < 2; i++ { + for range 2 { code, body, ok, err := s.Handle("/AchievementUpdate", req) if err != nil || code != 167 || !ok || len(body) != 0 { t.Fatalf("update: %d %x %v %v", code, body, ok, err) @@ -91,7 +91,7 @@ func TestAchievementInvalidRequestsDoNotMutate(t *testing.T) { func TestAchievementBatchRetryKeepsEarlierReceipt(t *testing.T) { s := achievementTestService(t, stateio.NewMemory()) requests := [][]byte{achievementRequest(10, 7, 1), achievementRequest(11, 7, 2)} - for retry := 0; retry < 2; retry++ { + for range 2 { for _, request := range requests { if _, _, _, err := s.Handle("/AchievementUpdate", request); err != nil { t.Fatal(err) @@ -165,7 +165,11 @@ func TestAchievementTransactionRollbackAndReopen(t *testing.T) { if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() s = achievementTestService(t, repo) if achievementValue(t, s) != 1 { t.Fatal("SQLite reopen lost count") @@ -185,7 +189,11 @@ func TestAchievementTransactionRollbackAndReopen(t *testing.T) { if err != nil { t.Fatal(err) } - defer recovered.Close() + defer func() { + if err := recovered.Close(); err != nil { + t.Error(err) + } + }() s = achievementTestService(t, recovered) if achievementValue(t, s) != 1 { t.Fatal("rolled-back increment survived") diff --git a/go/internal/server/world/event_field_pack_test.go b/go/internal/server/world/event_field_pack_test.go index 35e324d..6b13064 100644 --- a/go/internal/server/world/event_field_pack_test.go +++ b/go/internal/server/world/event_field_pack_test.go @@ -67,7 +67,7 @@ func TestHiddenPackPurchaseEntryMonstersAndOutsideRestore(t *testing.T) { if _, _, _, err = s.Handle("/PackInGameInfo", r); err == nil { t.Fatal("unbought hidden pack admitted") } - for i := 0; i < 2; i++ { + for range 2 { code, out, ok, err := s.Handle("/PackBuy", r) if err != nil || code != 6 || !ok { t.Fatalf("buy %d %v", code, err) diff --git a/go/internal/server/world/field_buff_damage_test.go b/go/internal/server/world/field_buff_damage_test.go index d217637..ad50fd3 100644 --- a/go/internal/server/world/field_buff_damage_test.go +++ b/go/internal/server/world/field_buff_damage_test.go @@ -22,17 +22,27 @@ func TestMonsterFieldDamagePartyFractionAndRequestReplay(t *testing.T) { if e != nil { t.Fatal(e) } - chars.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }) - chars.EnsurePersisted() - chars.SetCurrentHealth(77, 70) - chars.SetCurrentHealth(88, 0) + if err := chars.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }); err != nil { + t.Fatal(err) + } + if err := chars.EnsurePersisted(); err != nil { + t.Fatal(err) + } + if err := chars.SetCurrentHealth(77, 70); err != nil { + t.Fatal(err) + } + if err := chars.SetCurrentHealth(88, 0); err != nil { + t.Fatal(err) + } s.characters = chars s.decks, e = deck.NewStore(deck.Seed{Version: versionconfig.State(), FieldCharControlDeckType: 1, FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 77}, {Slot: 2, CharacterInvenIndex: 88}}}) if e != nil { t.Fatal(e) } s.fieldBuffs = map[uint64]gamedata.FieldBuffDesign{4: {ID: 4, Type: 5, TargetType: 1, Value: .25}, 5: {ID: 5, Type: 4, TargetType: 0, Value: 10}} - s.AttachFieldMonsterState(store) + if err := s.AttachFieldMonsterState(store); err != nil { + t.Fatal(err) + } s.BeginSession("login") s.AttachFieldMonsterDamage(s.applyMonsterFieldDamage) s.monsterLoader = func(int) ([]gamedata.FieldMonsterDesign, error) { @@ -56,7 +66,9 @@ func TestMonsterFieldDamagePartyFractionAndRequestReplay(t *testing.T) { if hp != 45 { t.Fatal("retry damaged again", hp) } - chars.SetCurrentHealth(77, 0) + if err := chars.SetCurrentHealth(77, 0); err != nil { + t.Fatal(err) + } rows, e := s.applyMonsterFieldDamage(21, 5, "test") if e != nil || len(rows) != 1 { t.Fatal("dead leader should remain the controlled leader", e) diff --git a/go/internal/server/world/field_chest_open_wrapper_test.go b/go/internal/server/world/field_chest_open_wrapper_test.go index 13829d5..8b7c58f 100644 --- a/go/internal/server/world/field_chest_open_wrapper_test.go +++ b/go/internal/server/world/field_chest_open_wrapper_test.go @@ -47,7 +47,11 @@ func TestInstalledFieldChestOpenWrapperProtocolAndSQLiteRollback(t *testing.T) { if err != nil { t.Fatal(err) } - defer func() { repo.Close() }() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() store := &fieldRewardFailStore{Repository: repo} service := func() *Service { s := sqliteFieldService(t, store).WithFieldObjects(map[int]gamedata.FieldObjectDesign{1: design}) diff --git a/go/internal/server/world/field_monster_events.go b/go/internal/server/world/field_monster_events.go index b59e0a9..920b639 100644 --- a/go/internal/server/world/field_monster_events.go +++ b/go/internal/server/world/field_monster_events.go @@ -24,7 +24,7 @@ func (s *Service) grantFieldMonster(pack int, m gamedata.FieldMonsterDesign, ide return nil, e } for _, r := range rows { - rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 } } return s.researchEconomy.Apply(identity, nil, rewards) diff --git a/go/internal/server/world/field_monster_runtime_test.go b/go/internal/server/world/field_monster_runtime_test.go index 0bbd9ee..119bca4 100644 --- a/go/internal/server/world/field_monster_runtime_test.go +++ b/go/internal/server/world/field_monster_runtime_test.go @@ -12,7 +12,9 @@ import ( func TestPackDetailTracksKillRetryRespawnAndResearchAcrossRestart(t *testing.T) { s := testService() store := stateio.NewMemory() - s.AttachFieldMonsterState(store) + if err := s.AttachFieldMonsterState(store); err != nil { + t.Fatal(err) + } now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) s.monsterNow = func() time.Time { return now } s.packSummaryTargets = map[int]bool{21: true} diff --git a/go/internal/server/world/field_object_routes.go b/go/internal/server/world/field_object_routes.go index 1101db1..17bd38e 100644 --- a/go/internal/server/world/field_object_routes.go +++ b/go/internal/server/world/field_object_routes.go @@ -165,7 +165,7 @@ func (s *Service) handleFieldObjectRespawn(request []byte) (int, []byte, bool, e response = wire.AppendBytes(response, 1, wire.AppendVarint(nil, 1, uint64(id))) } } - if reset == 0 || reset == 3 { + if reset == 0 || reset == 3 { //nolint:staticcheck // QF1003 next, e := s.fieldReset.Next(reset, s.monsterTime()) if e != nil { return 30, nil, true, e diff --git a/go/internal/server/world/field_object_routes_test.go b/go/internal/server/world/field_object_routes_test.go index dfb8213..d468077 100644 --- a/go/internal/server/world/field_object_routes_test.go +++ b/go/internal/server/world/field_object_routes_test.go @@ -57,7 +57,11 @@ func TestFieldBatchSQLiteAtomicResetAndRestart(t *testing.T) { if err != nil { t.Fatal(err) } - defer func() { repo.Close() }() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() store := &fieldRewardFailStore{Repository: repo} s := sqliteFieldService(t, store) now := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) @@ -190,7 +194,11 @@ func TestFieldEffectsSQLitePersistenceRepeatAndBattleConsumption(t *testing.T) { if err != nil { t.Fatal(err) } - defer func() { repo.Close() }() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() store := &fieldRewardFailStore{Repository: repo} s := sqliteFieldService(t, store) designs := map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{ @@ -310,7 +318,11 @@ func TestInstalledFieldChestAndHealingObjectSQLiteSettlement(t *testing.T) { if err != nil { t.Fatal(err) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() store := &fieldRewardFailStore{Repository: repo} s := sqliteFieldService(t, store).WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: chests, 1: traps}) s.fieldBuffs = buffs @@ -342,7 +354,9 @@ func TestInstalledFieldChestAndHealingObjectSQLiteSettlement(t *testing.T) { if err != nil { t.Fatal(err) } - s.characters.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }) + if err := s.characters.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }); err != nil { + t.Fatal(err) + } if err := s.characters.EnsurePersisted(); err != nil { t.Fatal(err) } diff --git a/go/internal/server/world/field_objects.go b/go/internal/server/world/field_objects.go index 52a6a91..b07814b 100644 --- a/go/internal/server/world/field_objects.go +++ b/go/internal/server/world/field_objects.go @@ -218,7 +218,7 @@ func (s *Service) openFieldObject(pack, group, id int) ([]byte, error) { } switch r.Type { case 2, 3, 4, 12, 20: - rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 case 5, 7, 8, 9, 13, 14, 17, 19, 27, 29: if r.ID == 0 { return nil, fmt.Errorf("world: invalid field item") diff --git a/go/internal/server/world/field_objects_test.go b/go/internal/server/world/field_objects_test.go index 2fece51..822f50c 100644 --- a/go/internal/server/world/field_objects_test.go +++ b/go/internal/server/world/field_objects_test.go @@ -31,7 +31,11 @@ func TestFieldEquipmentTransactionRollbackAndRetry(t *testing.T) { if err != nil { t.Fatal(err) } - defer func() { repo.Close() }() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() store := &fieldRewardFailStore{Repository: repo} s := testService() s.state, err = progress.OpenStore(store) @@ -103,7 +107,9 @@ func TestFieldEquipmentTransactionRollbackAndRetry(t *testing.T) { t.Fatal(err) } if _, err := s.openFieldObject(21, 101, 1001); err != nil { - op.Rollback() + if rollbackErr := op.Rollback(); rollbackErr != nil { + t.Error(rollbackErr) + } t.Fatal(err) } if err := op.Commit(); err != nil { @@ -219,7 +225,11 @@ func TestFieldChestAtomicAwardRollback(t *testing.T) { if err != nil { t.Fatal(err) } - defer func() { repo.Close() }() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() store := &fieldRewardFailStore{Repository: repo} s := testService() s.state, err = progress.OpenStore(store) @@ -278,7 +288,9 @@ func TestFieldChestAtomicAwardRollback(t *testing.T) { t.Fatal(err) } if _, err = s.openFieldObject(21, 101, 1001); err != nil { - op.Rollback() + if rollbackErr := op.Rollback(); rollbackErr != nil { + t.Error(rollbackErr) + } t.Fatal(err) } if err = op.Commit(); err != nil { diff --git a/go/internal/server/world/monster_info.go b/go/internal/server/world/monster_info.go index 539bede..1147902 100644 --- a/go/internal/server/world/monster_info.go +++ b/go/internal/server/world/monster_info.go @@ -7,6 +7,7 @@ import ( "bytes" "encoding/json" "fmt" + "slices" "time" ) @@ -251,10 +252,8 @@ func (s *Service) authorizeMonsterMap(pack, id int) error { if e != nil { return e } - for _, mapID := range maps[id] { - if current == mapID { - return nil - } + if slices.Contains(maps[id], current) { + return nil } return fmt.Errorf("world: monster outside current map") } diff --git a/go/internal/server/world/monster_info_test.go b/go/internal/server/world/monster_info_test.go index 257d5a8..d3304de 100644 --- a/go/internal/server/world/monster_info_test.go +++ b/go/internal/server/world/monster_info_test.go @@ -73,7 +73,9 @@ func TestMonsterLifetimeSurvivesReaderRestartWithoutRenewal(t *testing.T) { s2 := testService() s2.monsterLoader = s.monsterLoader s2.monsterNow = s.monsterNow - s2.AttachFieldMonsterState(store) + if err := s2.AttachFieldMonsterState(store); err != nil { + t.Fatal(err) + } _, again, _, err := s2.Handle("/MonsterInfo", request) if err != nil || !bytes.Equal(first, again) { t.Fatal("restart renewed finite monster lifetime") diff --git a/go/internal/server/world/npc_reputation.go b/go/internal/server/world/npc_reputation.go index 9fc6271..d31387e 100644 --- a/go/internal/server/world/npc_reputation.go +++ b/go/internal/server/world/npc_reputation.go @@ -6,7 +6,7 @@ import ( "bd2server/internal/server/wire" "encoding/json" "fmt" - "sort" + "slices" "time" ) @@ -64,10 +64,7 @@ func (s *Service) reputationState(pack int, rule gamedata.NPCReputationRule) (ui if !ok { return 1, 0, nil } - elapsed := s.npcReputation.now().Unix() - start - if elapsed < 0 { - elapsed = 0 - } + elapsed := max(s.npcReputation.now().Unix()-start, 0) if uint64(elapsed) >= rule.DownHours*3600 { return 1, 0, nil } @@ -128,7 +125,7 @@ func (s *Service) npcReputationRows(pack int) ([][]byte, error) { for id := range d.Groups { groups = append(groups, id) } - sort.Slice(groups, func(i, j int) bool { return groups[i] < groups[j] }) + slices.Sort(groups) var rows [][]byte for _, id := range groups { state, elapsed, e := s.reputationState(pack, d.Groups[id]) diff --git a/go/internal/server/world/overwhelm.go b/go/internal/server/world/overwhelm.go index 5c1ee91..c9ea532 100644 --- a/go/internal/server/world/overwhelm.go +++ b/go/internal/server/world/overwhelm.go @@ -248,7 +248,7 @@ func (s *Service) handleOverwhelm(request []byte) (int, []byte, bool, error) { } rewards := make([]gamedata.Reward, 0, len(rs)) for _, r := range rs { - rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 } reward, e = s.researchEconomy.Apply(m.Instance, m.Costs, rewards) } else { diff --git a/go/internal/server/world/overwhelm_test.go b/go/internal/server/world/overwhelm_test.go index 3bf0871..1de3c98 100644 --- a/go/internal/server/world/overwhelm_test.go +++ b/go/internal/server/world/overwhelm_test.go @@ -37,7 +37,9 @@ func TestOverwhelmRewardAndReceiptRollbackTogether(t *testing.T) { t.Fatal(e) } s := testService() - s.AttachFieldMonsterState(overwhelmFailedState{repo}) + if err := s.AttachFieldMonsterState(overwhelmFailedState{repo}); err != nil { + t.Fatal(err) + } s.BeginSession("login") s.monsterLoader = func(int) ([]gamedata.FieldMonsterDesign, error) { return []gamedata.FieldMonsterDesign{{ID: 9, GroupID: 7, BattleDeck: 33, UseBattleSkip: 1, RegenSeconds: 10, Reward: gamedata.Reward{Type: 5, ID: 400, Count: 2}}}, nil @@ -54,12 +56,18 @@ func TestOverwhelmRewardAndReceiptRollbackTogether(t *testing.T) { if e = op.Rollback(); e != nil && !errors.Is(e, stateio.ErrStateRecoveryRequired) { t.Fatal(e) } - repo.Close() + if err := repo.Close(); err != nil { + t.Fatal(err) + } repo, e = accountstate.Open(path) if e != nil { t.Fatal(e) } - defer repo.Close() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() for _, name := range []string{"overwhelm_test_authorization", "overwhelm_test_reward", "field_monster_runtime"} { b, e := repo.Load(name) if e != nil || b != nil { @@ -82,7 +90,9 @@ func overwhelmRequest(seq uint64, ids ...uint64) []byte { func TestOverwhelmBatchPrevalidationAuthorizationAndGenerationReplay(t *testing.T) { s := testService() store := stateio.NewMemory() - s.AttachFieldMonsterState(store) + if err := s.AttachFieldMonsterState(store); err != nil { + t.Fatal(err) + } s.BeginSession("login") now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) s.monsterNow = func() time.Time { return now } diff --git a/go/internal/server/world/pack_purchase.go b/go/internal/server/world/pack_purchase.go index 19dbf4d..4c4e73c 100644 --- a/go/internal/server/world/pack_purchase.go +++ b/go/internal/server/world/pack_purchase.go @@ -64,7 +64,7 @@ func (s *Service) grantPurchaseRewards(identity string, rewards []gamedata.Rewar if reward.ID == 0 { return nil, fmt.Errorf("world: invalid pack ticket") } - itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) + itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) //nolint:staticcheck // S1016 } } var costumeIDs []uint64 diff --git a/go/internal/server/world/pack_purchase_test.go b/go/internal/server/world/pack_purchase_test.go index 224354c..407b283 100644 --- a/go/internal/server/world/pack_purchase_test.go +++ b/go/internal/server/world/pack_purchase_test.go @@ -28,7 +28,7 @@ func TestPaidPackUsesDesignCostAndCurrenciesOnce(t *testing.T) { t.Fatal(err) } s.storyCatalog.Packs[709] = gamedata.StoryPack{ID: 709, BuyType: 3, BuyPrice: 37, BuyRewards: []gamedata.Reward{{Type: 2, Count: 8}, {Type: 20, Count: 11}}} - for attempt := 0; attempt < 2; attempt++ { + for range 2 { if _, err := s.purchaseStoryPack(709, false); err != nil { t.Fatal(err) } @@ -64,7 +64,11 @@ func TestPackPurchaseFailureRollsBackSQLiteCurrencyTicketAndMarker(t *testing.T) if err != nil { t.Fatal(err) } - defer func() { repo.Close() }() + defer func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }() storage := &purchaseFailStore{Repository: repo} s := testService() s.seed.PackID = 709 @@ -131,7 +135,9 @@ func TestPackPurchaseFailureRollsBackSQLiteCurrencyTicketAndMarker(t *testing.T) t.Fatal(err) } if _, _, _, err := s.Handle("/PackBuy", request); err != nil { - op.Rollback() + if rollbackErr := op.Rollback(); rollbackErr != nil { + t.Error(rollbackErr) + } t.Fatal(err) } if err := op.Commit(); err != nil { diff --git a/go/internal/server/world/pack_summary.go b/go/internal/server/world/pack_summary.go index a0f24d4..eb75faa 100644 --- a/go/internal/server/world/pack_summary.go +++ b/go/internal/server/world/pack_summary.go @@ -26,7 +26,7 @@ func (s *Service) handlePackSummary(request []byte) (int, []byte, bool, error) { var once, regen uint64 for _, objectID := range ids { object := s.fieldObjects[int(id)].Objects[objectID] - if object.Type == 2 { + if object.Type == 2 { //nolint:staticcheck // QF1003 once++ } else if object.Type == 1 || object.Type == 3 { regen++ diff --git a/go/internal/server/world/quest_selection_test.go b/go/internal/server/world/quest_selection_test.go index 26bf02c..1603c23 100644 --- a/go/internal/server/world/quest_selection_test.go +++ b/go/internal/server/world/quest_selection_test.go @@ -53,7 +53,7 @@ func TestDifficultySelectionPersistsAndRewardsRemainIndependent(t *testing.T) { if _, _, _, err := s.Handle("/QuestAccept", selectionRequest(1, 21, 1)); err != nil { t.Fatal(err) } - for attempt := 0; attempt < 2; attempt++ { + for range 2 { if _, _, _, err := s.Handle("/QuestClear", selectionRequest(1, 21, 0)); err != nil { t.Fatal(err) } diff --git a/go/internal/server/world/story_catalog_test.go b/go/internal/server/world/story_catalog_test.go index 30bd6e4..bf60d37 100644 --- a/go/internal/server/world/story_catalog_test.go +++ b/go/internal/server/world/story_catalog_test.go @@ -115,7 +115,7 @@ func TestInitialPackPurchaseUsesDesignAndDurableIdentity(t *testing.T) { } s.storyCatalog = &gamedata.StoryCatalog{Packs: map[int]gamedata.StoryPack{707: {ID: 707, BuyRewards: []gamedata.Reward{{Type: 12, Count: 17}, {Type: 4, Count: 31}, {Type: 19, ID: 88, Count: 1}, {Type: 11, ID: 101, Count: 0}}}}} s.questCostumes = purchaseTestCostumes{101: {ID: 10, HP: 100, CostumeMaxLevel: 5}} - for i := 0; i < 2; i++ { + for range 2 { if err := s.EnsureInitialPackPurchase(); err != nil { t.Fatal(err) } diff --git a/go/internal/server/world/today_quests_test.go b/go/internal/server/world/today_quests_test.go index 82365a1..d96b60a 100644 --- a/go/internal/server/world/today_quests_test.go +++ b/go/internal/server/world/today_quests_test.go @@ -234,7 +234,8 @@ func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) { } } } - repo, s, wallet, inv := open() + var wallet *player.Wallet + repo, s, _, inv := open() savedDeck := []deck.DeckEntry{ {CharacterInvenIndex: 901, CostumeInvenIndex: 0, Slot: 1}, {CharacterInvenIndex: 905, CostumeInvenIndex: 8, Slot: 2}, @@ -260,7 +261,7 @@ func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) { if err := repo.Close(); err != nil { t.Fatal(err) } - repo, s, wallet, inv = open() + repo, s, _, _ = open() if !reflect.DeepEqual(s.decks.CurrentDeck(), savedDeck) { t.Fatal("SQLite reopen lost selected formation") } @@ -296,7 +297,7 @@ func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) { if err := repo.Close(); err != nil { t.Fatal(err) } - repo, s, wallet, inv = open() + _, s, wallet, _ = open() call(s, "/QuestClear", 102, 21) assertEntry(s, 21, map[int][]uint64{}, []int{101, 102}) if wallet.Snapshot().Gold != 10 { diff --git a/go/internal/server/world/world.go b/go/internal/server/world/world.go index 57dccf7..65028e6 100644 --- a/go/internal/server/world/world.go +++ b/go/internal/server/world/world.go @@ -559,13 +559,6 @@ func (s *Service) packUnlocked(packID int) bool { return s.storyCatalog != nil && s.storyPackUnlocked(packID) } -func (s *Service) storyCharacters(packID int) []player.Character { - if packID != s.seed.PackID { - return nil - } - return s.seed.StoryCharacters -} - func (s *Service) canClear(packID, quest int) bool { if s.state.QuestCleared(quest, packID, s.questDifficultyFor(packID, quest)) { return true @@ -625,12 +618,12 @@ func (s *Service) grantQuestRewards(packID, quest int, designRewards []gamedata. if reward.ID == 0 || reward.Count == 0 { return nil, nil, fmt.Errorf("world: invalid item reward type=%d id=%d count=%d", reward.Type, reward.ID, reward.Count) } - itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) + itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) //nolint:staticcheck // S1016 } } var costumeIDs []uint64 for _, reward := range designRewards { - if reward.Type == 11 && !(packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && s.questDifficultyFor(packID, quest) == 0 && reward.ID == s.seed.RewardCostume.ID) { + if reward.Type == 11 && !(packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && s.questDifficultyFor(packID, quest) == 0 && reward.ID == s.seed.RewardCostume.ID) { //nolint:staticcheck // QF1001 costumeIDs = append(costumeIDs, reward.ID) } } @@ -661,7 +654,7 @@ func (s *Service) grantQuestRewards(packID, quest int, designRewards []gamedata. return nil, nil, fmt.Errorf("world: unknown collection reward pack%d", packID) } for _, reward := range quests[quest].CollectionRewards { - itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) + itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) //nolint:staticcheck // S1016 } } var items []player.Item @@ -814,10 +807,6 @@ func (s *Service) basePackInfoFor(packID int) ([]byte, error) { return wire.AppendBytes(out, 16, group), nil } -func (s *Service) firstUnclearedQuest() int { - return s.firstUnclearedQuestFor(s.seed.PackID) -} - func (s *Service) firstUnclearedQuestFor(packID int) int { if s.storyCatalog == nil { return 0 @@ -922,10 +911,6 @@ func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Rewa return wire.AppendBytes(out, 13, nil) } -func (s *Service) packComplete() bool { - return s.packCompleteFor(s.seed.PackID) -} - func (s *Service) packCompleteFor(packID int) bool { if s.storyCatalog == nil { return false @@ -1019,10 +1004,6 @@ func encodeCostume(c player.Costume) []byte { return player.CostumeWire(c) } -func (s *Service) nextQuest(current int) int { - return s.nextQuestFor(s.seed.PackID, current) -} - func (s *Service) nextQuestFor(packID, current int) int { quests, found := s.questsFor(packID) if !found { diff --git a/plugins/.editorconfig b/plugins/.editorconfig index 0cda99a..6603b71 100644 --- a/plugins/.editorconfig +++ b/plugins/.editorconfig @@ -30,3 +30,6 @@ generated_code = true [*.generated.cs] generated_code = true + +[GameSdk/samples/**/*.cs] +dotnet_analyzer_diagnostic.severity = none diff --git a/plugins/CaptureEnvironment/CaptureStorageIsolation.cs b/plugins/CaptureEnvironment/CaptureStorageIsolation.cs index 6e5d08f..fe55232 100644 --- a/plugins/CaptureEnvironment/CaptureStorageIsolation.cs +++ b/plugins/CaptureEnvironment/CaptureStorageIsolation.cs @@ -43,9 +43,7 @@ internal static class CaptureStorageIsolation { PropertyInfo persistent = typeof(Application).GetGameProperty( "persistentDataPath", BindingFlags.Static | BindingFlags.Public); - MethodInfo getter = persistent?.GetGetMethod(); - if (getter == null) - throw new MissingMethodException("Application.persistentDataPath getter not found"); + MethodInfo getter = (persistent?.GetGetMethod()) ?? throw new MissingMethodException("Application.persistentDataPath getter not found"); harmony.Patch(getter, prefix: new HarmonyMethod( typeof(CaptureStorageIsolation), nameof(PersistentDataPathPrefix))); diff --git a/plugins/CaptureEnvironment/CaptureWriter.cs b/plugins/CaptureEnvironment/CaptureWriter.cs index 401046a..12687c5 100644 --- a/plugins/CaptureEnvironment/CaptureWriter.cs +++ b/plugins/CaptureEnvironment/CaptureWriter.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System; using System.Collections.Concurrent; using System.IO; @@ -13,9 +14,9 @@ internal static class CaptureWriter private const int MaxBodyBytes = 16 * 1024 * 1024; private const int MaxQueuedRecords = 256; private const int WriterShutdownSeconds = 30; - private static readonly object FailureFileLock = new object(); + private static readonly object FailureFileLock = new(); private static readonly BlockingCollection WriteQueue = - new BlockingCollection(new ConcurrentQueue(), MaxQueuedRecords); + new(new ConcurrentQueue(), MaxQueuedRecords); private static ManualLogSource Log; internal static string DirectoryPath { get; private set; } private static string JsonlPath; @@ -27,7 +28,7 @@ internal static class CaptureWriter internal static void Initialize(string root, ManualLogSource log) { Log = log; - DirectoryPath = Path.GetFullPath(Path.Combine(root, "Capture", DateTime.Now.ToString("yyyyMMdd-HHmmss"))); + DirectoryPath = Path.GetFullPath(Path.Combine(root, "Capture", DateTime.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture))); JsonlPath = Path.Combine(DirectoryPath, "capture.jsonl"); ReadableLogPath = Path.Combine(DirectoryPath, "capture.log"); Directory.CreateDirectory(Path.Combine(DirectoryPath, "bodies")); @@ -210,11 +211,11 @@ internal static class CaptureWriter if (record.Body == null || record.Body.Length == 0) return null; string type = record.Type ?? record.Path ?? "protobuf"; int dot = type.LastIndexOf('.'); - if (dot >= 0) type = type.Substring(dot + 1); + if (dot >= 0) type = type[(dot + 1)..]; string safe = new string(type.Select(ch => char.IsLetterOrDigit(ch) ? ch : '_') .ToArray()).Trim('_'); if (safe.Length == 0) safe = "protobuf"; - string name = record.Id.ToString("D6") + "_" + record.Direction + + string name = record.Id.ToString("D6", CultureInfo.InvariantCulture) + "_" + record.Direction + "_" + safe + ".pb"; File.WriteAllBytes(Path.Combine(DirectoryPath, "bodies", name), record.Body); return "bodies/" + name; @@ -236,7 +237,7 @@ internal static class CaptureWriter private static string ReadableLine(CaptureRecord record, string bodyFile) { string direction = record.Direction == "request" ? "REQ" : "RESP"; - return string.Format("{0,-33} {1,6:D6} {2,-4} {3,-40} {4,-50} {5,8} {6}", + return string.Format(CultureInfo.InvariantCulture, "{0,-33} {1,6:D6} {2,-4} {3,-40} {4,-50} {5,8} {6}", record.Timestamp.ToString("O"), record.Id, direction, Truncate(record.Path, 40), Truncate(record.Type, 50), record.Length, bodyFile ?? record.Note ?? "-"); @@ -245,7 +246,7 @@ internal static class CaptureWriter private static string Truncate(string value, int width) { value ??= ""; - return value.Length <= width ? value : value.Substring(0, width - 1) + "…"; + return value.Length <= width ? value : value[..(width - 1)] + "…"; } private static string JsonString(string value) @@ -270,7 +271,7 @@ internal static class CaptureWriter case '\t': escaped.Append("\\t"); break; default: if (character <= '\u001f') - escaped.Append("\\u").Append(((int)character).ToString("x4")); + escaped.Append("\\u").Append(((int)character).ToString("x4", CultureInfo.InvariantCulture)); else escaped.Append(character); break; diff --git a/plugins/CaptureEnvironment/PacketCapture.cs b/plugins/CaptureEnvironment/PacketCapture.cs index 26db24c..670f340 100644 --- a/plugins/CaptureEnvironment/PacketCapture.cs +++ b/plugins/CaptureEnvironment/PacketCapture.cs @@ -13,9 +13,9 @@ namespace Bd2CaptureEnvironment; internal static class PacketCapture { - private static readonly object CorrelationLock = new object(); + private static readonly object CorrelationLock = new(); private static readonly Dictionary> PendingRequests = - new Dictionary>(StringComparer.Ordinal); + new(StringComparer.Ordinal); private static ManualLogSource Log; private static long Sequence; private sealed class PendingRequest @@ -105,7 +105,7 @@ internal static class PacketCapture { const string suffix = "Request"; if (typeName != null && typeName.EndsWith(suffix, StringComparison.Ordinal)) - typeName = typeName.Substring(0, typeName.Length - suffix.Length); + typeName = typeName[..^suffix.Length]; return NormalizePath(typeName); } @@ -120,7 +120,7 @@ internal static class PacketCapture const string suffix = "Request"; if (!string.IsNullOrEmpty(requestType) && requestType.EndsWith(suffix, StringComparison.Ordinal)) - return requestType.Substring(0, requestType.Length - suffix.Length) + "Response"; + return requestType[..^suffix.Length] + "Response"; return "Proto.Net." + path.TrimStart('/') + "Response"; } diff --git a/plugins/CaptureEnvironment/Plugin.cs b/plugins/CaptureEnvironment/Plugin.cs index 878ecd2..c9f704f 100644 --- a/plugins/CaptureEnvironment/Plugin.cs +++ b/plugins/CaptureEnvironment/Plugin.cs @@ -8,13 +8,14 @@ using UnityEngine; namespace Bd2CaptureEnvironment; -[BepInPlugin(Guid, Name, Version)] +[BepInPlugin(PluginId, Name, Version)] public sealed class Plugin : BaseUnityPlugin { - public const string Guid = "bd2.capture.environment"; + public const string PluginId = "bd2.capture.environment"; public const string Name = "BD2 Capture Environment"; public const string Version = Bd2Build.Versions.Plugin; + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0051", Justification = "Unity invokes this instance lifecycle callback.")] private void Awake() { try @@ -23,7 +24,10 @@ public sealed class Plugin : BaseUnityPlugin CaptureStorageIsolation.Initialize(Paths.GameRootPath, Logger); CaptureWriter.Initialize(Paths.GameRootPath, Logger); - var harmony = new Harmony(Guid); + // Harmony.Dispose calls UnpatchSelf; these hooks live until process exit. +#pragma warning disable CA2000 + var harmony = new Harmony(PluginId); +#pragma warning restore CA2000 CaptureStorageIsolation.Install(harmony); string effectiveDataPath = Application.persistentDataPath; if (!string.Equals( @@ -47,6 +51,8 @@ public sealed class Plugin : BaseUnityPlugin } } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822", Justification = "Unity invokes this instance lifecycle callback.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0051", Justification = "Unity invokes this instance lifecycle callback.")] private void OnApplicationQuit() { CaptureWriter.Stop(); diff --git a/plugins/CaptureEnvironment/README.md b/plugins/CaptureEnvironment/README.md index 98c8a58..18034bf 100644 --- a/plugins/CaptureEnvironment/README.md +++ b/plugins/CaptureEnvironment/README.md @@ -1,69 +1,3 @@ # BD2 official capture environment plugin -This BepInEx plugin is only for the separately extracted official client selected by the repository-root `versions.json`. - -The SDK embeds the version-locked names table. Build with the game directory; the build generates the readable reference assembly and reobfuscates the plugin automatically: - -```powershell -dotnet build plugins/CaptureEnvironment/CaptureEnvironment.csproj -c Release ` - '-p:GameDir=' -``` - -Deploy the runtime `BD2CaptureEnvironment.dll` together with the adjacent -`BD2.GameNames.dll`. The launcher copies both from the staged build and requires -both to exist. The shared library validates the plugin/table version and game -binary before game patches are installed. See [the GameSdk guide](../GameSdk/README.md) -for the compiler/runtime name contract; the readable shell stays in `obj`. - -- redirects `Application.persistentDataPath` to `/IsolatedUserData`; -- prefixes game `PlayerPrefs` keys with a namespace derived from the configured client version; -- keeps account/profile/Neo/Intro and the installed GameData working set in - `/IsolatedUserData`; `NetworkManager.GetPachedGameDataPath()` uses the - private `Data/t` directory and refuses a linked GameData path; -- uses `PluginLocalRes.dll` to read the explicitly supplied, reviewed - ServerData/GameData source; SoundData and VideoData are shared static-data - junctions, while the versioned Addressables catalog cache remains private; - player saves and account data are never linked or copied from the primary - client; -- uses the official client archive's own `StreamingAssets/aa` bundle store; -- captures BD2 API request/response bodies without a proxy or TLS certificate; -- captures protobuf requests before encryption and responses after AES decryption; -- writes through a bounded asynchronous queue; queue overflow or any writer - failure creates `INCOMPLETE.txt`, rejects later packets with an error in the - BepInEx log, and prevents a partial capture from looking complete; -- drains the writer queue during normal application shutdown; if the drain - cannot finish within the shutdown safety deadline, the capture is marked - incomplete instead of silently dropping the tail; -- omits Cookie/Authorization headers and URL query strings; -- skips GET/static resources and bodies larger than 16 MiB. - -Request and response protobuf records share one correlation ID when the client -provides the request path. Batch responses are captured after each item is -decrypted. JSONL retains the complete path and type; the human-readable log -truncates those two display columns only. Treat a capture containing -`INCOMPLETE.txt` as unusable evidence and repeat it. - -The raw body files may still contain private account data. Do not publish the -generated `Capture` directory. This plugin does not redirect the game API and -must not be installed together with `BD2LocalIdentity.dll` in the official -comparison client. - -Unity starts writing its default Player log before BepInEx can patch the data -path. Always launch through `tools/python/start-official-capture.ps1`; it supplies an -explicit isolated `-logFile` path and validates static-data junctions. The -launcher requires the official-client directory argument. - -Pass them explicitly, for example: - -```powershell -.\tools\python\start-official-capture.ps1 ` - -GameDir "" ` - -ResourceRoot "" -``` - -The launcher reads bundle and GameData versions from the repository-root -`versions.json`. `-VersionConfig`, `-BundleVersion`, and `-GameDataVersion` -remain explicit development overrides. - -The launcher derives the shared user-data directory for the current Windows -account; `-SharedDataDir` overrides it when the game uses another location. +官服抓包插件。插件依赖 `BD2LocalIdentity.dll` 与 `BD2.GameNames.dll`。 \ No newline at end of file diff --git a/plugins/CashShop/AttendanceRewardEnvelope.cs b/plugins/CashShop/AttendanceRewardEnvelope.cs index 0dd8940..89f7d9c 100644 --- a/plugins/CashShop/AttendanceRewardEnvelope.cs +++ b/plugins/CashShop/AttendanceRewardEnvelope.cs @@ -1,4 +1,3 @@ -using System; using Google.Protobuf; namespace Bd2CashShop; diff --git a/plugins/CashShop/CommercePayments.cs b/plugins/CashShop/CommercePayments.cs index 1cc6cae..0d882fc 100644 --- a/plugins/CashShop/CommercePayments.cs +++ b/plugins/CashShop/CommercePayments.cs @@ -6,7 +6,7 @@ namespace Bd2CashShop; internal static class CommercePayments { - private static readonly HashSet Applied = new HashSet(); + private static readonly HashSet Applied = []; internal static string Receipt(long payment, CommerceProduct quote) => "bd2-local-commerce-v1:" + payment.ToString(CultureInfo.InvariantCulture) + ":" + diff --git a/plugins/CashShop/CommercePriceLayout.cs b/plugins/CashShop/CommercePriceLayout.cs index 0540092..eb0f3ad 100644 --- a/plugins/CashShop/CommercePriceLayout.cs +++ b/plugins/CashShop/CommercePriceLayout.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using TMPro; using UnityEngine; @@ -17,13 +16,13 @@ internal sealed class CommercePriceLayout : MonoBehaviour private float fontSize; private bool autoSize, wrap; private TextAlignmentOptions alignment; - private readonly List> drivers = new List>(); + private readonly List> drivers = []; private readonly Vector3[] corners = new Vector3[4]; internal static void Apply(RectTransform region, UISprite sprite, TMP_Text text) { if (region == null || sprite == null || text == null) return; - var layout = region.GetComponent() ?? region.gameObject.AddComponent(); + CommercePriceLayout layout = region.GetComponent() ?? region.gameObject.AddComponent(); layout.Restore(); layout.region = region; layout.icon = (RectTransform)sprite.transform; @@ -45,6 +44,7 @@ internal sealed class CommercePriceLayout : MonoBehaviour layout.Arrange(); } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0031", Justification = "UnityEngine.Object null checks also detect destroyed native objects.")] internal static void Reset(RectTransform region) { if (region != null) region.GetComponent()?.Restore(); @@ -52,7 +52,7 @@ internal sealed class CommercePriceLayout : MonoBehaviour private void CaptureDrivers(RectTransform target) { - foreach (var driver in target.GetComponents()) + foreach (Behaviour driver in target.GetComponents()) if (driver is ContentSizeFitter || driver is AspectRatioFitter) { drivers.Add(new KeyValuePair(driver, driver.enabled)); @@ -60,7 +60,9 @@ internal sealed class CommercePriceLayout : MonoBehaviour } } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0051", Justification = "Unity invokes this instance lifecycle callback.")] private void LateUpdate() => Arrange(); + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0051", Justification = "Unity invokes this instance lifecycle callback.")] private void OnDisable() => Restore(); private void Arrange() @@ -72,7 +74,7 @@ internal sealed class CommercePriceLayout : MonoBehaviour float middle = region.rect.center.y; // Reserve the actual caption width in this price strip, even after a // language, screen size or font change. Decorative labels are ignored. - foreach (var label in region.GetComponentsInChildren()) + foreach (TMP_Text label in region.GetComponentsInChildren()) { if (label == price || !label.gameObject.activeInHierarchy || string.IsNullOrWhiteSpace(label.text)) continue; label.rectTransform.GetWorldCorners(corners); @@ -125,11 +127,11 @@ internal sealed class CommercePriceLayout : MonoBehaviour price.enableWordWrapping = wrap; price.alignment = alignment; } - foreach (var driver in drivers) if (driver.Key != null) driver.Key.enabled = driver.Value; + foreach (KeyValuePair driver in drivers) if (driver.Key != null) driver.Key.enabled = driver.Value; drivers.Clear(); } - private struct RectState + private readonly struct RectState { private readonly Vector2 min, max, pivot, position, size; private readonly Vector3 scale; @@ -138,7 +140,7 @@ internal sealed class CommercePriceLayout : MonoBehaviour min = rect.anchorMin; max = rect.anchorMax; pivot = rect.pivot; position = rect.anchoredPosition; size = rect.sizeDelta; scale = rect.localScale; } - internal void Restore(RectTransform rect) + internal readonly void Restore(RectTransform rect) { rect.anchorMin = min; rect.anchorMax = max; rect.pivot = pivot; rect.anchoredPosition = position; rect.sizeDelta = size; rect.localScale = scale; diff --git a/plugins/CashShop/PassRewardPresentation.cs b/plugins/CashShop/PassRewardPresentation.cs index 0ad4c08..a7584e7 100644 --- a/plugins/CashShop/PassRewardPresentation.cs +++ b/plugins/CashShop/PassRewardPresentation.cs @@ -19,33 +19,30 @@ internal static class PassRewardPresentation private sealed class Capture { internal Capture Previous; - internal readonly List Rewards = new List(); + internal readonly List Rewards = []; } private sealed class Batch { - internal readonly List Rewards = new List(); + internal readonly List Rewards = []; } internal static void Install(Harmony harmony) { - var receiver = typeof(MissionPassPacket).GetNestedTypes(All) + MethodInfo receiver = typeof(MissionPassPacket).GetNestedTypes(All) .SelectMany(type => type.GetMethods(All | BindingFlags.DeclaredOnly)) .Single(method => method.IsGameMethod("b__1") && method.ReturnType == typeof(bool) && method.GetParameters().Select(parameter => parameter.ParameterType) - .SequenceEqual(new[] { typeof(byte[]), typeof(int), typeof(int) })); + .SequenceEqual((Type[])[typeof(byte[]), typeof(int), typeof(int)])); harmony.Patch(receiver, prefix: new HarmonyMethod(typeof(PassRewardPresentation), nameof(BeginReceive)), postfix: new HarmonyMethod(typeof(PassRewardPresentation), nameof(EndReceive)), finalizer: new HarmonyMethod(typeof(PassRewardPresentation), nameof(FinishReceive))); - var addRewards = typeof(DataManager).GetGameMethod("AddRewardInfoBundle", All, null, - new[] { typeof(Proto.Net.RewardDBInfoBundle), typeof(List).MakeByRefType(), typeof(bool) }, null); - if (addRewards == null) throw new MissingMethodException("DataManager.AddRewardInfoBundle"); + MethodInfo addRewards = typeof(DataManager).GetGameMethod("AddRewardInfoBundle", All, null, + [typeof(Proto.Net.RewardDBInfoBundle), typeof(List).MakeByRefType(), typeof(bool)], null) ?? throw new MissingMethodException("DataManager.AddRewardInfoBundle"); harmony.Patch(addRewards, postfix: new HarmonyMethod(typeof(PassRewardPresentation), nameof(CaptureRewards))); - var receiveAll = typeof(PassRootUI).GetGameMethod("ReceiveAllReward", All); - if (receiveAll == null) throw new MissingMethodException("PassRootUI.ReceiveAllReward"); + MethodInfo receiveAll = typeof(PassRootUI).GetGameMethod("ReceiveAllReward", All) ?? throw new MissingMethodException("PassRootUI.ReceiveAllReward"); harmony.Patch(receiveAll, postfix: new HarmonyMethod(typeof(PassRewardPresentation), nameof(WrapReceiveAll))); - var popup = typeof(MissionPassPacket).GetGameMethod("ShowRewardPopup", All); - if (popup == null) throw new MissingMethodException("MissionPassPacket.ShowRewardPopup"); + MethodInfo popup = typeof(MissionPassPacket).GetGameMethod("ShowRewardPopup", All) ?? throw new MissingMethodException("MissionPassPacket.ShowRewardPopup"); harmony.Patch(popup, prefix: new HarmonyMethod(typeof(PassRewardPresentation), nameof(MergeBatch))); } @@ -74,7 +71,7 @@ internal static class PassRewardPresentation private static IEnumerator ReceiveAll(IEnumerator original) { var owner = new Batch(); - var previous = batch; + Batch previous = batch; batch = owner; try { @@ -89,7 +86,7 @@ internal static class PassRewardPresentation private static void MergeBatch(ref List __0) { if (batch == null || batch.Rewards.Count == 0) return; - var combined = __0 == null ? new List() : new List(__0); + List combined = __0 == null ? [] : new List(__0); combined.AddRange(batch.Rewards); batch.Rewards.Clear(); __0 = combined; diff --git a/plugins/CashShop/Plugin.cs b/plugins/CashShop/Plugin.cs index 0a1dc6a..73ac346 100644 --- a/plugins/CashShop/Plugin.cs +++ b/plugins/CashShop/Plugin.cs @@ -31,9 +31,9 @@ public sealed class Plugin : BaseUnityPlugin private static bool applicationQuitting; private static Dictionary products; private static string catalogOrigin; - private static readonly Dictionary priceViews = new Dictionary(); - private static readonly HashSet attendanceReceipts = new HashSet(StringComparer.Ordinal); - private static readonly HashSet attendanceRefreshPayments = new HashSet(); + private static readonly Dictionary priceViews = []; + private static readonly HashSet attendanceReceipts = new(StringComparer.Ordinal); + private static readonly HashSet attendanceRefreshPayments = []; private sealed class PriceLabelState { internal string Text; @@ -44,7 +44,7 @@ public sealed class Plugin : BaseUnityPlugin internal bool Layout; internal Dictionary Drivers; } - private static readonly Dictionary> unavailablePriceLabels = new Dictionary>(); + private static readonly Dictionary> unavailablePriceLabels = []; private static BepInEx.Logging.ManualLogSource Log; private const string Unavailable = "当前服务器未开通购买功能"; private const string Marker = "), typeof(string), typeof(string), typeof(string), - typeof(Action), typeof(bool), typeof(bool), typeof(MessagePopupUI.EMessagePopupButtonStyle) }, null); - if (paymentPopup == null) throw new MissingMethodException("UIManager payment confirmation overload"); + [ typeof(Action), typeof(string), typeof(string), typeof(string), + typeof(Action), typeof(bool), typeof(bool), typeof(MessagePopupUI.EMessagePopupButtonStyle) ], null) ?? throw new MissingMethodException("UIManager payment confirmation overload"); harmony.Patch(paymentPopup, prefix: new HarmonyMethod(typeof(Plugin), nameof(SuppressExternalPaymentConfirmation))); Patch(typeof(PackagePurchaseButton), "SetPrice", nameof(PackagePrice), true, 0); Patch(typeof(PassRootUI), "CheckIsInAppBuyButton", nameof(PassPrice), true, 0); @@ -90,8 +89,7 @@ public sealed class Plugin : BaseUnityPlugin RewardBuffRefresh.Install(harmony); PrestigeSkinRewardPresentation.Install(harmony, Log); MethodInfo attendance = typeof(EventPacket).GetGameMethod("RecvAttendanceResponse", All, null, - new[] { typeof(IMessage), typeof(int), typeof(int) }, null); - if (attendance == null) throw new MissingMethodException("EventPacket.RecvAttendanceResponse(IMessage,int,int)"); + [typeof(IMessage), typeof(int), typeof(int)], null) ?? throw new MissingMethodException("EventPacket.RecvAttendanceResponse(IMessage,int,int)"); harmony.Patch(attendance, postfix: new HarmonyMethod(typeof(Plugin), nameof(AttendanceRewards))); Patch(typeof(IntroUI), "Awake", nameof(IntroAwake), true, 0); EnsureCommerceHost(); @@ -104,14 +102,14 @@ public sealed class Plugin : BaseUnityPlugin } } - private void Patch(Type type, string name, string callback, bool postfix, int count) + private static void Patch(Type type, string name, string callback, bool postfix, int count) { MethodInfo target = type.GetMethods(All).Single(method => method.IsGameMethod(name) && method.GetParameters().Length == count); var patch = new HarmonyMethod(typeof(Plugin), callback); harmony.Patch(target, prefix: postfix ? null : patch, postfix: postfix ? patch : null); } - private void PatchGeneratedReceiver(string readableName, string callback) + private static void PatchGeneratedReceiver(string readableName, string callback) { // Compiler-generated declaring types are found by their mapped methods; // neither an obfuscated type name nor a generated class number is pinned. @@ -119,7 +117,7 @@ public sealed class Plugin : BaseUnityPlugin .SelectMany(type => type.GetMethods(All | BindingFlags.DeclaredOnly)) .Single(method => method.IsGameMethod(readableName) && method.ReturnType == typeof(bool) && method.GetParameters().Select(parameter => parameter.ParameterType) - .SequenceEqual(new[] { typeof(byte[]), typeof(int), typeof(int) })); + .SequenceEqual((Type[])[typeof(byte[]), typeof(int), typeof(int)])); harmony.Patch(target, postfix: new HarmonyMethod(typeof(Plugin), callback)); } @@ -146,7 +144,10 @@ public sealed class Plugin : BaseUnityPlugin public sealed class CommerceHost : MonoBehaviour { + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822", Justification = "Unity invokes this instance lifecycle callback.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0051", Justification = "Unity invokes this instance lifecycle callback.")] private void OnApplicationQuit() { applicationQuitting = true; } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0051", Justification = "Unity invokes this instance lifecycle callback.")] private void OnDestroy() { if (ReferenceEquals(host, this)) host = null; @@ -164,7 +165,7 @@ public sealed class Plugin : BaseUnityPlugin if (origin != catalogOrigin) { products = null; catalogOrigin = origin; nextFetch = 0; RefreshPrices(); } if (origin != null && Time.realtimeSinceStartup >= nextFetch) { - Task> request = Task.Run(() => Fetch(origin)); + var request = Task.Run(() => Fetch(origin)); while (!request.IsCompleted) { string current = null; @@ -194,26 +195,24 @@ public sealed class Plugin : BaseUnityPlugin if (new Uri(origin).IsLoopback || string.IsNullOrWhiteSpace(proxy)) request.Proxy = null; else { - if (!Uri.TryCreate(proxy, UriKind.Absolute, out var proxyUri) || proxyUri.Scheme != "http" || proxyUri.UserInfo.Length != 0 || proxyUri.AbsolutePath != "/" || proxyUri.Query.Length != 0 || proxyUri.Fragment.Length != 0) + if (!Uri.TryCreate(proxy, UriKind.Absolute, out Uri proxyUri) || proxyUri.Scheme != "http" || proxyUri.UserInfo.Length != 0 || proxyUri.AbsolutePath != "/" || proxyUri.Query.Length != 0 || proxyUri.Fragment.Length != 0) throw new InvalidOperationException("Unsupported client commerce proxy"); request.Proxy = new WebProxy(proxyUri); } - using (var response = (HttpWebResponse)request.GetResponse()) - using (var stream = response.GetResponseStream()) - using (var reader = new StreamReader(stream)) + using var response = (HttpWebResponse)request.GetResponse(); + using Stream stream = response.GetResponseStream(); + using var reader = new StreamReader(stream); + if (response.StatusCode != HttpStatusCode.OK) throw new IOException("Commerce HTTP status " + response.StatusCode); + var bodyBuilder = new System.Text.StringBuilder(); + char[] buffer = new char[8192]; + int read; + while ((read = reader.Read(buffer, 0, buffer.Length)) != 0) { - if (response.StatusCode != HttpStatusCode.OK) throw new IOException("Commerce HTTP status " + response.StatusCode); - var bodyBuilder = new System.Text.StringBuilder(); - var buffer = new char[8192]; - int read; - while ((read = reader.Read(buffer, 0, buffer.Length)) != 0) - { - if (bodyBuilder.Length + read > 4 * 1024 * 1024) throw new IOException("Commerce catalog too large"); - bodyBuilder.Append(buffer, 0, read); - } - string body = bodyBuilder.ToString(); - return JsonConvert.DeserializeObject(body).Validate(Bd2Build.Versions.Game); + if (bodyBuilder.Length + read > 4 * 1024 * 1024) throw new IOException("Commerce catalog too large"); + bodyBuilder.Append(buffer, 0, read); } + string body = bodyBuilder.ToString(); + return JsonConvert.DeserializeObject(body).Validate(Bd2Build.Versions.Game); } private static CommerceProduct Resolve(string sku) @@ -222,7 +221,7 @@ public sealed class Plugin : BaseUnityPlugin { if (products == null || catalogOrigin != Origin() || sku == null) return null; CashProductTable table = CashShopInfo.GetCashProductDTO(sku, SG.App.MarketType); - if (table == null || !products.TryGetValue(CommerceProduct.MakeKey(table.GroupId, table.Id, table.SaleGroup, sku), out var policy)) return null; + if (table == null || !products.TryGetValue(CommerceProduct.MakeKey(table.GroupId, table.Id, table.SaleGroup, sku), out CommerceProduct policy)) return null; if (table == null || table.PriceType != 1 || table.GroupId != policy.GroupId || table.Id != policy.ProductId || table.SaleGroup != policy.SaleGroup || CashShopInfo.GetProductIdByMarket(table, SG.App.MarketType) != sku) return null; return policy; @@ -234,17 +233,17 @@ public sealed class Plugin : BaseUnityPlugin { try { - var table = CashShopInfo.GetCashProductDTO(group, id, sale); + CashProductTable table = CashShopInfo.GetCashProductDTO(group, id, sale); if (table == null || table.PriceType != 1 || products == null || catalogOrigin != Origin()) return null; string sku = CashShopInfo.GetProductIdByMarket(table, SG.App.MarketType); - return products.TryGetValue(CommerceProduct.MakeKey(group, id, sale, sku), out var policy) ? policy : null; + return products.TryGetValue(CommerceProduct.MakeKey(group, id, sale, sku), out CommerceProduct policy) ? policy : null; } catch { return null; } } private static bool Purchase(string __0, object __1, Action __2) { - var policy = Resolve(__0); + CommerceProduct policy = Resolve(__0); if (policy == null || !policy.Enabled) { __2?.Invoke(); @@ -355,7 +354,7 @@ public sealed class Plugin : BaseUnityPlugin private static bool PriceLabel(string __0, ref string __result) { - var policy = Resolve(__0); + CommerceProduct policy = Resolve(__0); string text = policy == null || !policy.Enabled ? Unavailable : policy.ItemType == 0 ? "Free" : policy.Amount.ToString(CultureInfo.InvariantCulture); __result = Marker + Uri.EscapeDataString(__0 ?? "") + ">" + text + ""; return false; @@ -368,7 +367,7 @@ public sealed class Plugin : BaseUnityPlugin if (start < 0) return null; start += Marker.Length; int end = label.IndexOf('>', start); - return end < 0 ? null : Resolve(Uri.UnescapeDataString(label.Substring(start, end - start))); + return end < 0 ? null : Resolve(Uri.UnescapeDataString(label[start..end])); } private static bool SetCash(CurrencyButton __instance, string __0) @@ -399,8 +398,8 @@ public sealed class Plugin : BaseUnityPlugin private static void RestorePricePrefixes(CurrencyButton button) { - if (button == null || !unavailablePriceLabels.TryGetValue(button, out var saved)) return; - foreach (var entry in saved) + if (button == null || !unavailablePriceLabels.TryGetValue(button, out Dictionary saved)) return; + foreach (KeyValuePair entry in saved) if (entry.Key != null) { entry.Key.text = entry.Value.Text; @@ -413,7 +412,7 @@ public sealed class Plugin : BaseUnityPlugin rect.anchorMin = entry.Value.AnchorMin; rect.anchorMax = entry.Value.AnchorMax; rect.pivot = entry.Value.Pivot; rect.offsetMin = entry.Value.OffsetMin; rect.offsetMax = entry.Value.OffsetMax; if (entry.Value.Drivers != null) - foreach (var driver in entry.Value.Drivers) if (driver.Key != null) driver.Key.enabled = driver.Value; + foreach (KeyValuePair driver in entry.Value.Drivers) if (driver.Key != null) driver.Key.enabled = driver.Value; } } unavailablePriceLabels.Remove(button); @@ -458,19 +457,28 @@ public sealed class Plugin : BaseUnityPlugin GameObject buy = Field(popup, "_objBuyButton"), cancel = Field(popup, "_objCancelButton"); TMP_Text price = button.TextPrice; var saved = new Dictionary(); - foreach (var label in container.GetComponentsInChildren(true)) + foreach (TMP_Text label in container.GetComponentsInChildren(true)) { if (label == price || buy != null && label.transform.IsChildOf(buy.transform) || cancel != null && label.transform.IsChildOf(cancel.transform)) continue; if (label.text != "购买费用") continue; RectTransform rect = label.rectTransform; - saved[label] = new PriceLabelState { Text = label.text, Active = label.gameObject.activeSelf, - Layout = true, Alignment = label.alignment, Wrap = label.enableWordWrapping, - AnchorMin = rect.anchorMin, AnchorMax = rect.anchorMax, Pivot = rect.pivot, - OffsetMin = rect.offsetMin, OffsetMax = rect.offsetMax }; + saved[label] = new PriceLabelState + { + Text = label.text, + Active = label.gameObject.activeSelf, + Layout = true, + Alignment = label.alignment, + Wrap = label.enableWordWrapping, + AnchorMin = rect.anchorMin, + AnchorMax = rect.anchorMax, + Pivot = rect.pivot, + OffsetMin = rect.offsetMin, + OffsetMax = rect.offsetMax + }; var drivers = new Dictionary(); - foreach (var group in container.GetComponentsInChildren(true)) + foreach (UnityEngine.UI.LayoutGroup group in container.GetComponentsInChildren(true)) { drivers[group] = group.enabled; group.enabled = false; } - foreach (var fitter in container.GetComponentsInChildren(true)) + foreach (UnityEngine.UI.ContentSizeFitter fitter in container.GetComponentsInChildren(true)) { drivers[fitter] = fitter.enabled; fitter.enabled = false; } saved[label].Drivers = drivers; label.text = Unavailable; @@ -485,14 +493,23 @@ public sealed class Plugin : BaseUnityPlugin Log?.LogWarning("Commerce price layout: fee label missing inside CurrencyParent; centering existing price label"); if (price == null) return; RectTransform rect = price.rectTransform; - saved[price] = new PriceLabelState { Text = price.text, Active = price.gameObject.activeSelf, - Layout = true, Alignment = price.alignment, Wrap = price.enableWordWrapping, - AnchorMin = rect.anchorMin, AnchorMax = rect.anchorMax, Pivot = rect.pivot, - OffsetMin = rect.offsetMin, OffsetMax = rect.offsetMax }; + saved[price] = new PriceLabelState + { + Text = price.text, + Active = price.gameObject.activeSelf, + Layout = true, + Alignment = price.alignment, + Wrap = price.enableWordWrapping, + AnchorMin = rect.anchorMin, + AnchorMax = rect.anchorMax, + Pivot = rect.pivot, + OffsetMin = rect.offsetMin, + OffsetMax = rect.offsetMax + }; var drivers = new Dictionary(); - foreach (var group in container.GetComponentsInChildren(true)) + foreach (UnityEngine.UI.LayoutGroup group in container.GetComponentsInChildren(true)) { drivers[group] = group.enabled; group.enabled = false; } - foreach (var fitter in container.GetComponentsInChildren(true)) + foreach (UnityEngine.UI.ContentSizeFitter fitter in container.GetComponentsInChildren(true)) { drivers[fitter] = fitter.enabled; fitter.enabled = false; } saved[price].Drivers = drivers; price.text = Unavailable; @@ -522,13 +539,14 @@ public sealed class Plugin : BaseUnityPlugin throw new MissingFieldException(target.GetType().FullName, name); } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0031", Justification = "UnityEngine.Object null checks also detect destroyed native objects.")] private static void PackagePrice(PackagePurchaseButton __instance) { priceViews.Remove(__instance); int group = Field(__instance, "_productGroupId"), id = Field(__instance, "_productId"), sale = Field(__instance, "_productSaleGroupId"); - var table = CashShopInfo.GetCashProductDTO(group, id, sale); + CashProductTable table = CashShopInfo.GetCashProductDTO(group, id, sale); if (table == null || table.PriceType != 1) return; - var button = Field(__instance, "_btnCurrency"); + CurrencyButton button = Field(__instance, "_btnCurrency"); priceViews[__instance] = () => PackagePrice(__instance); CommerceProduct policy = Resolve(group, id, sale); TMP_Text label = Field(__instance, "_textPrice"); @@ -547,23 +565,23 @@ public sealed class Plugin : BaseUnityPlugin { priceViews.Remove(__instance); if (!__result) return; - var table = PassInfo.GetPassBuyTable(Field(__instance, "_curPassTableID"), Define_PassBuyType.PbPremium2); + PassBuyTable table = PassInfo.GetPassBuyTable(Field(__instance, "_curPassTableID"), Define_PassBuyType.PbPremium2); priceViews[__instance] = () => PassPrice(__instance, true); Render(Field(__instance, "_imgPremiumBuyPrice"), Field(__instance, "_txtPremiumBuyPrice"), Resolve(table.CashProductGroupId, table.CashProductId, table.CashSalesGroup)); } private static void PassPopupPrice(BuyConfirmPopupUI __instance, int __0, Define_PassBuyType __1) { - var price = Field(__instance, "_textCurrency"); + TMP_Text price = Field(__instance, "_textCurrency"); CommercePriceLayout.Reset(price?.transform.parent as RectTransform); priceViews.Remove(__instance); if (__1 != Define_PassBuyType.PbPremium2) return; - var table = PassInfo.GetPassBuyTable(__0, __1); + PassBuyTable table = PassInfo.GetPassBuyTable(__0, __1); int pass = __0; Define_PassBuyType buy = __1; priceViews[__instance] = () => PassPopupPrice(__instance, pass, buy); - var policy = Resolve(table.CashProductGroupId, table.CashProductId, table.CashSalesGroup); - var sprite = Field(__instance, "_uiSpriteCurrency"); + CommerceProduct policy = Resolve(table.CashProductGroupId, table.CashProductId, table.CashSalesGroup); + UISprite sprite = Field(__instance, "_uiSpriteCurrency"); Render(sprite, price, policy); if (policy != null && policy.Enabled && policy.ItemType != 0) CommercePriceLayout.Apply(price.transform.parent as RectTransform, sprite, price); @@ -581,13 +599,13 @@ public sealed class Plugin : BaseUnityPlugin private static void GachaPrice(GachaProductButtonElement __instance, string __4) { - var buttons = Field(__instance, "_currencyGachas"); + CurrencyButton[] buttons = Field(__instance, "_currencyGachas"); if (buttons == null || buttons.Length == 0) return; - foreach (var button in buttons) if (button != null) priceViews.Remove(button); + foreach (CurrencyButton button in buttons) if (button != null) priceViews.Remove(button); if (__4 == null || !__4.Contains(Marker)) return; string sku = ExtractSku(__4); priceViews[buttons[0]] = () => Render(buttons[0], Resolve(sku)); - foreach (var button in buttons) button?.SetActive(false); + foreach (CurrencyButton button in buttons) button?.SetActive(false); Render(buttons[0], FromLabel(__4)); Field(__instance, "_textDescription").text = ""; } @@ -644,18 +662,19 @@ public sealed class Plugin : BaseUnityPlugin { int start = label.IndexOf(Marker, StringComparison.Ordinal) + Marker.Length; int end = label.IndexOf('>', start); - return end < 0 ? null : Uri.UnescapeDataString(label.Substring(start, end - start)); + return end < 0 ? null : Uri.UnescapeDataString(label[start..end]); } private static void RefreshPrices() { - foreach (var view in priceViews.ToArray()) + foreach (KeyValuePair view in priceViews.ToArray()) { if (view.Key == null) { priceViews.Remove(view.Key); continue; } try { view.Value(); } catch (Exception error) { Log?.LogWarning("Commerce UI refresh: " + error.Message); } } } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822", Justification = "Unity invokes this instance lifecycle callback.")] private void OnDestroy() { // Unity may remove the BepInEx component during startup. Static hooks and diff --git a/plugins/CashShop/PrestigeSkinRewardPresentation.cs b/plugins/CashShop/PrestigeSkinRewardPresentation.cs index 0a89525..14728c6 100644 --- a/plugins/CashShop/PrestigeSkinRewardPresentation.cs +++ b/plugins/CashShop/PrestigeSkinRewardPresentation.cs @@ -16,10 +16,9 @@ internal static class PrestigeSkinRewardPresentation internal static void Install(Harmony harmony, ManualLogSource logger) { log = logger; - var target = typeof(PrestigeSkinGetUI).GetGameMethod("RecursiveShowPrestigeSkin", + MethodInfo target = typeof(PrestigeSkinGetUI).GetGameMethod("RecursiveShowPrestigeSkin", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, null, - new[] { typeof(Queue), typeof(Action) }, null); - if (target == null) throw new MissingMethodException("PrestigeSkinGetUI.RecursiveShowPrestigeSkin"); + [typeof(Queue), typeof(Action)], null) ?? throw new MissingMethodException("PrestigeSkinGetUI.RecursiveShowPrestigeSkin"); harmony.Patch(target, prefix: new HarmonyMethod(typeof(PrestigeSkinRewardPresentation), nameof(ClosePreviousWindows))); } @@ -27,7 +26,7 @@ internal static class PrestigeSkinRewardPresentation { // Snapshot both aliases before removing either window. CloseProcess // releases assets and clears the end-step callback; it does not invoke it. - var prestige = UIManager.GetUI(); + PrestigeSkinGetUI prestige = UIManager.GetUI(); var special = UIManager.GetUI("SpecialSkinGetUI") as PrestigeSkinGetUI; int closed = 0; if (prestige != null) diff --git a/plugins/CashShop/README.md b/plugins/CashShop/README.md index 8b5f996..2df71b8 100644 --- a/plugins/CashShop/README.md +++ b/plugins/CashShop/README.md @@ -1,19 +1,3 @@ # BD2 Cash Shop -把当前游戏的现金支付入口替换为本服的游戏内货币购买。插件依赖 `BD2LocalIdentity.dll`,目标游戏版本在项目中明确声明为 `2.35.10`,使用共享 `BD2.GameNames.dll` 和 GameSdk 构建。 - -普通现金商品固定使用付费钻石。充值商品的获取方式由服务端 `game.json` 的 `purchases.diamond_recharge` 设置:默认免费,也可使用金币、普通钻石,或者禁止充值。完整配置和换算说明见 [GAME_CONFIGURATION.md](../../GAME_CONFIGURATION.md#现金商品与付费钻石充值)。 - -客户端从当前服务器 `/client/commerce` 获取完整价格目录。配置未加载、版本不匹配或禁止购买时,购买入口会显示游戏提示框,不会调用外部支付 SDK。收费商品使用游戏原有货币图标与数量;免费充值显示 `Free`。 - -补丁保留原购买回调,因此无限抽最终确认、特殊甄选券、月卡、礼包、皮肤和现金通行证继续执行各自的游戏流程。原本使用普通钻石、付费钻石、金币等游戏内货币的商品不修改价格类型。服务器独立验证商品、价格、余额、购买次数、开放时间和接力前置条件,并在一个存档事务内完成扣款、权益激活与现金邮件创建;客户端发送的价格仅用于检测过期目录。 - -现金商品按真实 `RewardGroupTable.MailId` 发放:带邮件模板的奖励进入现金邮箱,未标记的通行证、月卡等资格按游戏规则立即生效,嵌套奖励组也使用同一规则。邮件沿用 `MailInfoTable` 的本地化标题和正文,现金附件不过期。邮寄部分在购买时只创建附件,`CashMailInfo` 返回现金邮件,玩家通过游戏原有 `MailOpen` 单封或批量领取后才将附件写入背包和货币数据。购买与领取都有持久回执,重试或重启不会重复扣款、重复发信或重复发奖;以前已经直接发放的订单保留原回执,不会再补发同一份附件。 - -月卡、登录通行礼包和付费签到的领取状态在服务器保存,`AttendanceResponse` 与 `EventRewardResponse` 的本服扩展字段同步每日奖励并用回执防止重复应用。现金通行证激活必须消费购买权益。主线通关礼包按服务器主线进度领取;恶魔城奖励需要对应玩法提供真实完成记录,目前占位玩法不能领取通关奖励。 - -```powershell -dotnet build plugins/CashShop/CashShop.csproj -c Release -``` - -客户端开发入口和发布包会同时构建、安装此插件。手动部署时使用 `bin/Release/netstandard2.1/BD2CashShop.dll`,与 LocalIdentity 及共享运行时一起放入本服游戏的 `BepInEx/plugins`。原版客户端与官方抓包环境不要安装这个插件。 +把当前游戏的现金支付入口替换为本服的游戏内货币购买,与本服务器配套使用。插件依赖 `BD2LocalIdentity.dll` 与 `BD2.GameNames.dll`。 \ No newline at end of file diff --git a/plugins/CashShop/RewardBuffRefresh.cs b/plugins/CashShop/RewardBuffRefresh.cs index 9a1846f..db17229 100644 --- a/plugins/CashShop/RewardBuffRefresh.cs +++ b/plugins/CashShop/RewardBuffRefresh.cs @@ -13,17 +13,16 @@ internal static class RewardBuffRefresh { internal static void Install(Harmony harmony) { - var target = typeof(DataManager).GetGameMethod("AddRewardInfoBundle", + MethodInfo target = typeof(DataManager).GetGameMethod("AddRewardInfoBundle", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, null, - new[] { typeof(RewardDBInfoBundle), typeof(List).MakeByRefType(), typeof(bool) }, null); - if (target == null) throw new MissingMethodException("DataManager.AddRewardInfoBundle"); + [typeof(RewardDBInfoBundle), typeof(List).MakeByRefType(), typeof(bool)], null) ?? throw new MissingMethodException("DataManager.AddRewardInfoBundle"); harmony.Patch(target, postfix: new HarmonyMethod(typeof(RewardBuffRefresh), nameof(Refresh))); } private static void Refresh(RewardDBInfoBundle __0, bool __2) { if (__0 == null || !__2) return; - foreach (var item in __0.ViewItemInfo) + foreach (ItemDBInfo item in __0.ViewItemInfo) { if (item.Type != (int)EElementType.BuffItem) continue; ContentsPacket.SendRefreshCollectionBuff(null); diff --git a/plugins/GameNames/Game.cs b/plugins/GameNames/Game.cs index d83ab4e..5609ab8 100644 --- a/plugins/GameNames/Game.cs +++ b/plugins/GameNames/Game.cs @@ -22,7 +22,7 @@ public static class Game internal readonly Dictionary Tokens; internal Index() { - using (var stream = typeof(Game).Assembly.GetManifestResourceStream("BD2.GameNames.names.json.gz")) + using (Stream stream = typeof(Game).Assembly.GetManifestResourceStream("BD2.GameNames.names.json.gz")) using (var gzip = new GZipStream(stream ?? throw new InvalidDataException("BD2.GameNames: embedded table missing"), CompressionMode.Decompress)) Table = (NameTable)new DataContractJsonSerializer(typeof(NameTable)).ReadObject(gzip); if (Table.schema_version != 1) throw new InvalidDataException("BD2.GameNames: unsupported table schema"); @@ -30,10 +30,10 @@ public static class Game Tokens = Table.members.ToDictionary(m => m.token); } } - private static readonly Lazy Names = new Lazy(() => new Index()); - private static readonly ConcurrentDictionary TypeCache = new ConcurrentDictionary(); - private static readonly ConcurrentDictionary MemberCache = new ConcurrentDictionary(); - private static readonly object ValidationLock = new object(); + private static readonly Lazy Names = new(() => new Index()); + private static readonly ConcurrentDictionary TypeCache = new(); + private static readonly ConcurrentDictionary MemberCache = new(); + private static readonly object ValidationLock = new(); private static string ValidatedStamp; /// Gets the game version embedded in this package. public static string GameVersion => Names.Value.Table.game_version; @@ -42,18 +42,18 @@ public static class Game public static string TypeName(string readableFullName) { if (readableFullName == null) throw new ArgumentNullException(nameof(readableFullName)); - return Names.Value.Types.TryGetValue(readableFullName.Replace('/', '+'), out var entry) ? entry.original : readableFullName; + return Names.Value.Types.TryGetValue(readableFullName.Replace('/', '+'), out TypeName entry) ? entry.original : readableFullName; } /// Finds a readable type in Assembly-CSharp or loaded assemblies; returns null when absent. public static Type FindType(string readableFullName) { if (readableFullName == null) throw new ArgumentNullException(nameof(readableFullName)); - if (TypeCache.TryGetValue(readableFullName, out var cached)) return cached; + if (TypeCache.TryGetValue(readableFullName, out Type cached)) return cached; string original = TypeName(readableFullName); Type found = GameAssembly().GetType(original, false); if (found == null) - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) { found = assembly.GetType(original, false); if (found != null) break; @@ -69,7 +69,7 @@ public static class Game if (readable == null) throw new ArgumentNullException(nameof(readable)); // Metadata tokens retain declaration identity through inheritance and closed generics. // A hidden literal member must not be mistaken for a renamed member of a base class. - if (member.DeclaringType.Assembly.GetName().Name == Names.Value.Table.assembly_name && Names.Value.Tokens.TryGetValue(member.MetadataToken, out var entry)) + if (member.DeclaringType.Assembly.GetName().Name == Names.Value.Table.assembly_name && Names.Value.Tokens.TryGetValue(member.MetadataToken, out MemberName entry)) return entry.kind == kind && entry.readable == readable; return member.Name == readable; } @@ -88,7 +88,7 @@ public static class Game _ => throw new ArgumentOutOfRangeException(nameof(kind)) }; const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; - var reflected = kind switch + IEnumerable reflected = kind switch { GameMemberKind.Method => type.GetMethods(all).Cast(), GameMemberKind.Field => type.GetFields(all).Cast(), @@ -96,7 +96,7 @@ public static class Game GameMemberKind.Event => type.GetEvents(all).Cast(), _ => throw new ArgumentOutOfRangeException(nameof(kind)) }; - var names = reflected.Where(m => Matches(m, key, readable)).Select(m => m.Name).Distinct().ToArray(); + string[] names = reflected.Where(m => Matches(m, key, readable)).Select(m => m.Name).Distinct().ToArray(); if (names.Length == 0) return readable; if (names.Length != 1) throw new AmbiguousMatchException(type.FullName + "." + readable + ": supply the member signature"); return names[0]; @@ -110,9 +110,9 @@ public static class Game { if (method == null) throw new ArgumentNullException(nameof(method)); if (readable == null) throw new ArgumentNullException(nameof(readable)); - if (method.DeclaringType.Assembly.GetName().Name == Names.Value.Table.assembly_name && Names.Value.Tokens.TryGetValue(method.MetadataToken, out var entry)) + if (method.DeclaringType.Assembly.GetName().Name == Names.Value.Table.assembly_name && Names.Value.Tokens.TryGetValue(method.MetadataToken, out MemberName entry)) { - var p = entry.parameters.SingleOrDefault(n => n.readable == readable); + ParameterName p = entry.parameters.SingleOrDefault(n => n.readable == readable); if (p != null) return p.original; } return readable; @@ -144,7 +144,7 @@ public static class Game if (readable == null) throw new ArgumentNullException(nameof(readable)); if (type == null) return null; string key = type.AssemblyQualifiedName + "|" + kind + "|" + readable + "|" + (int)flags + "|" + (parameters == null ? "*" : string.Join(";", parameters.Select(p => p.AssemblyQualifiedName))); - if (MemberCache.TryGetValue(key, out var cached)) return (T)cached; + if (MemberCache.TryGetValue(key, out MemberInfo cached)) return (T)cached; T found = resolve(); if (found != null) MemberCache.TryAdd(key, found); return found; @@ -161,7 +161,7 @@ public static class Game if (type == null) return null; MethodInfo Resolve() { - var candidates = type.GetMethods(flags).Where(m => Matches(m, "method", readable)).ToArray(); + MethodInfo[] candidates = type.GetMethods(flags).Where(m => Matches(m, "method", readable)).ToArray(); if (candidates.Length == 0) return null; return (MethodInfo)(binder ?? Type.DefaultBinder).SelectMethod(flags, candidates, parameters, modifiers); } @@ -171,7 +171,7 @@ public static class Game } private static T Single(IEnumerable members, Type type, string readable) where T : MemberInfo { - var candidates = members.Take(2).ToArray(); + T[] candidates = members.Take(2).ToArray(); if (candidates.Length > 1) throw new AmbiguousMatchException(type.FullName + "." + readable + ": supply the member signature"); return candidates.FirstOrDefault(); } @@ -203,30 +203,30 @@ public static class Game { try { - var table = Names.Value.Table; + NameTable table = Names.Value.Table; string stamp = table.game_version + "|" + table.assembly_sha256 + "|" + table.mapping_sha256; - var metadata = plugin?.GetCustomAttributes().SingleOrDefault(a => a.Key == "BD2.GameNames"); + AssemblyMetadataAttribute metadata = plugin?.GetCustomAttributes().SingleOrDefault(a => a.Key == "BD2.GameNames"); if (expectedVersion != table.game_version || plugin != null && metadata?.Value != stamp) throw new InvalidDataException("plugin/table mismatch; rebuild the plugin and BD2.GameNames together"); lock (ValidationLock) { if (ValidatedStamp != stamp) { - var assembly = GameAssembly(); + Assembly assembly = GameAssembly(); if (assembly.ManifestModule.ModuleVersionId.ToString() != table.assembly_mvid) throw new InvalidDataException("Assembly-CSharp MVID mismatch"); using (var sha = SHA256.Create()) - using (var stream = File.OpenRead(assembly.Location)) + using (FileStream stream = File.OpenRead(assembly.Location)) { string actual = BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", "").ToLowerInvariant(); if (actual != table.assembly_sha256) throw new InvalidDataException("Assembly-CSharp SHA-256 mismatch"); } const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; - var app = FindType("AppManager") ?? throw new TypeLoadException("AppManager"); - var intro = FindType("IntroUI") ?? throw new TypeLoadException("IntroUI"); - var network = FindType("BDNetwork.NetworkManager") ?? throw new TypeLoadException("BDNetwork.NetworkManager"); + Type app = FindType("AppManager") ?? throw new TypeLoadException("AppManager"); + Type intro = FindType("IntroUI") ?? throw new TypeLoadException("IntroUI"); + Type network = FindType("BDNetwork.NetworkManager") ?? throw new TypeLoadException("BDNetwork.NetworkManager"); if (app.GetGameProperty("IsPlatformLogin", all)?.PropertyType != typeof(bool) || - intro.GetGameMethod("SendMaintenanceInfo", all, null, new[] { typeof(bool) }, null) == null || + intro.GetGameMethod("SendMaintenanceInfo", all, null, [typeof(bool)], null) == null || network.GetGameMethod("GetPachedGameDataPath", all, null, Type.EmptyTypes, null)?.ReturnType != typeof(string)) throw new MissingMemberException("known game-name probes failed"); ValidatedStamp = stamp; diff --git a/plugins/GameNames/GameNames.csproj b/plugins/GameNames/GameNames.csproj index 1a2a9a7..c2dcdd2 100644 --- a/plugins/GameNames/GameNames.csproj +++ b/plugins/GameNames/GameNames.csproj @@ -1,6 +1,8 @@ + BD2.GameNames + true netstandard2.0 latest BD2.GameNames @@ -23,5 +25,6 @@ + diff --git a/plugins/GameNames/LICENSE b/plugins/GameNames/LICENSE new file mode 100644 index 0000000..c6487f4 --- /dev/null +++ b/plugins/GameNames/LICENSE @@ -0,0 +1,176 @@ +GNU LESSER GENERAL PUBLIC LICENSE + +Version 2.1, February 1999 + +Copyright (C) 1991, 1999 Free Software Foundation, Inc. +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] + +Preamble + +The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. + +This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. + +When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. + +To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. + +For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. + +We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. + +To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. + +Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. + +Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. + +When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. + +We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. + +For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. + +In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. + +Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. + +The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. + +GNU LESSER GENERAL PUBLIC LICENSE +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". + +A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. + +The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) + +"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. + +Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. + +1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. + +You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. + +(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. + +3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. + +Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. + +This option is useful when you wish to copy part of the code of the Library into a program that is not a library. + +4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. + +If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. + +5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. + +However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. + +When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. + +If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) + +Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. + +6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. + +You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: + + a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. + + e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. + +For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. + +It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. + +7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. + + b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. + +8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. + +9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. + +10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. + +11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. + +This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. + +12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. + +13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. + +14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. + +NO WARRANTY + +15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Libraries + +If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). + +To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + one line to give the library's name and an idea of what it does. + Copyright (C) year name of author + + This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: + +Yoyodyne, Inc., hereby disclaims all copyright interest in +the library `Frob' (a library for tweaking knobs) written +by James Random Hacker. + +signature of Ty Coon, 1 April 1990 +Ty Coon, President of Vice +That's all there is to it! diff --git a/plugins/GameNames/NameTable.cs b/plugins/GameNames/NameTable.cs index 6c564fa..38f8ea8 100644 --- a/plugins/GameNames/NameTable.cs +++ b/plugins/GameNames/NameTable.cs @@ -1,10 +1,14 @@ +#pragma warning disable IDE0005 using System.Collections.Generic; +#pragma warning restore IDE0005 using System.Runtime.Serialization; // DataContractJsonSerializer populates these internal fields from the embedded table. #pragma warning disable CS0649 +#pragma warning disable IDE0130 namespace BD2.GameNames.Internal; +#pragma warning restore IDE0130 [DataContract] internal sealed class NameTable @@ -16,8 +20,8 @@ internal sealed class NameTable [DataMember] public string assembly_sha256; [DataMember] public string mapping_sha256; [DataMember] public string generator_sha256; - [DataMember] public List types = new List(); - [DataMember] public List members = new List(); + [DataMember] public List types = []; + [DataMember] public List members = []; } [DataContract] @@ -37,7 +41,7 @@ internal sealed class MemberName [DataMember] public string readable; [DataMember] public string original; [DataMember] public string signature; - [DataMember] public List parameters = new List(); + [DataMember] public List parameters = []; } [DataContract] diff --git a/plugins/GameNames/README.md b/plugins/GameNames/README.md index 6f2cf59..77bc0fc 100644 --- a/plugins/GameNames/README.md +++ b/plugins/GameNames/README.md @@ -5,7 +5,7 @@ Brown Dust II 插件的共享名字解析运行时,目标框架为 `.NET Stand 开发插件建议引用配套的 `BD2.GameSdk` NuGet 包,它会自动引入本包并接入可读引用程序集和构建期 reobf。只有需要字符串反射、完全不引用游戏类型的插件才单独引用本包。 ```xml - + ``` ```csharp @@ -20,10 +20,10 @@ var enter = intro?.GetGameMethod("Enter", BindingFlags.Instance | BindingFlags.N 未知名字保留字面名;查不到成员返回 null,歧义抛出 `AmbiguousMatchException`。表中私有/编译器生成成员可通过字符串通道解析,公开调用可使用 `BD2.GameSdk` 生成的壳接受编译器检查。`nameof` 和协程字符串需显式经过名字表,普通字符串不会被 reobf 改写。 -单独引用本包的插件使用 `Game.ValidateGame` 校验游戏和内嵌表;`Game.Validate` 还会校验 SDK 在插件中写入的编译指纹。运行环境只需要共享 DLL,无需 .NET SDK、Mono.Cecil 或构建工具。同一客户端只安装一份 `BD2.GameNames.dll`,不同表指纹的插件会在启动时明确报错。 +单独引用本包的插件需使用 `Game.ValidateGame` 校验游戏和内嵌表,`Game.Validate` 还会校验 SDK 在插件中写入的编译指纹。运行环境只需要本插件的 DLL。同一客户端只安装一份 `BD2.GameNames.dll`,不同表指纹的插件会在启动时明确报错。 单独引用本包的 SDK-style 类库在 `.csproj` 中设置 `true`,让运行时 DLL 复制到 `bin`;`BD2.GameSdk` 会自动设置此默认值。 -包版本使用 `工具语义版本-game.游戏版本`,例如 `0.2.0-game.2.35.10`;游戏版本由 `Game.GameVersion` 获取。游戏版本变化后必须更新配套 SDK/运行时并重新构建插件。遵循包中附带的 LICENSE;本包并非 MIT 或其他开源许可。 +游戏版本变化后必须更新配套 SDK/运行时并重新构建插件。遵循包中附带的 LICENSE;本包并非 MIT 或其他开源许可。 -完整导入和构建说明:[BD2.GameSdk 文档](https://github.com/Flechazo098/bd2/blob/main/plugins/GameSdk/README.md)。包目前由仓库打包脚本生成,通过维护者提供的 NuGet 源或本地目录安装。 +完整导入和构建说明:[BD2.GameSdk 文档](https://github.com/Flechazo098/bd2/blob/main/plugins/GameSdk/README.md)。 diff --git a/plugins/GameSdk.targets b/plugins/GameSdk.targets index bf8fad3..0ed1547 100644 --- a/plugins/GameSdk.targets +++ b/plugins/GameSdk.targets @@ -13,7 +13,7 @@ - + diff --git a/plugins/GameSdk/GameSdk.csproj b/plugins/GameSdk/GameSdk.csproj index bbc790a..1dafcb7 100644 --- a/plugins/GameSdk/GameSdk.csproj +++ b/plugins/GameSdk/GameSdk.csproj @@ -1,6 +1,7 @@ + BD2.GameSdk Exe net8.0 enable diff --git a/plugins/GameSdk/LICENSE b/plugins/GameSdk/LICENSE new file mode 100644 index 0000000..c6487f4 --- /dev/null +++ b/plugins/GameSdk/LICENSE @@ -0,0 +1,176 @@ +GNU LESSER GENERAL PUBLIC LICENSE + +Version 2.1, February 1999 + +Copyright (C) 1991, 1999 Free Software Foundation, Inc. +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] + +Preamble + +The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. + +This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. + +When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. + +To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. + +For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. + +We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. + +To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. + +Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. + +Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. + +When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. + +We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. + +For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. + +In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. + +Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. + +The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. + +GNU LESSER GENERAL PUBLIC LICENSE +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". + +A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. + +The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) + +"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. + +Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. + +1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. + +You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. + +(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. + +3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. + +Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. + +This option is useful when you wish to copy part of the code of the Library into a program that is not a library. + +4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. + +If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. + +5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. + +However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. + +When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. + +If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) + +Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. + +6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. + +You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: + + a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. + + e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. + +For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. + +It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. + +7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. + + b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. + +8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. + +9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. + +10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. + +11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. + +This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. + +12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. + +13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. + +14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. + +NO WARRANTY + +15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Libraries + +If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). + +To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + one line to give the library's name and an idea of what it does. + Copyright (C) year name of author + + This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: + +Yoyodyne, Inc., hereby disclaims all copyright interest in +the library `Frob' (a library for tweaking knobs) written +by James Random Hacker. + +signature of Ty Coon, 1 April 1990 +Ty Coon, President of Vice +That's all there is to it! diff --git a/plugins/GameSdk/Pack.ps1 b/plugins/GameSdk/Pack.ps1 deleted file mode 100644 index 4334f1a..0000000 --- a/plugins/GameSdk/Pack.ps1 +++ /dev/null @@ -1,60 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)] [string]$GameDir, - [string]$VersionConfig, - [string]$PackageVersion, - [string]$OutputDirectory -) -$ErrorActionPreference = 'Stop' -if (-not $VersionConfig) { $VersionConfig = Join-Path $PSScriptRoot '..\..\versions.json' } -if (-not $OutputDirectory) { $OutputDirectory = Join-Path $PSScriptRoot '..\..\.build\nuget' } -$repository = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')) -$versions = Get-Content -LiteralPath $VersionConfig -Raw | ConvertFrom-Json -if (-not $PackageVersion) { - [xml]$metadata = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\PackageMetadata.props') -Raw - $PackageVersion = "$($metadata.Project.PropertyGroup.BD2PackageVersion)-game.$($versions.game_version)" -} -if ($PackageVersion -notmatch '^\d+\.\d+\.\d+-game\.\d+\.\d+\.\d+(?:\.[A-Za-z0-9-]+)*$') { - throw 'PackageVersion must be SemVer with game version, for example 0.2.1-game.2.35.10.' -} -if ($PackageVersion -notmatch ('-game\.' + [regex]::Escape([string]$versions.game_version) + '(?:\.|$)')) { - throw 'PackageVersion game suffix must match VersionConfig game_version.' -} -$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) -$staging = Join-Path $repository ".build\nuget-staging\$PackageVersion" -$sdk = Join-Path $staging 'sdk' -$tool = Join-Path $staging 'tool' -$runtimeObj = Join-Path $staging 'runtime-obj' -$runtimeBin = Join-Path $staging 'runtime-bin' -$packageObj = Join-Path $staging 'package-obj' -$packageBin = Join-Path $staging 'package-bin' -New-Item -ItemType Directory -Force -Path $OutputDirectory, $staging | Out-Null -function Invoke-Dotnet([string[]]$Arguments) { - & dotnet @Arguments - if ($LASTEXITCODE -ne 0) { throw "dotnet failed with exit code $LASTEXITCODE" } -} -$toolVersion = $PackageVersion.Split('-')[0] -Invoke-Dotnet @('publish', (Join-Path $PSScriptRoot 'GameSdk.csproj'), '-c', 'Release', '--nologo', '-o', $tool, "-p:Version=$toolVersion", '-p:UseAppHost=false') -$assembly = Join-Path $GameDir 'BrownDust II_Data\Managed\Assembly-CSharp.dll' -$toolDll = Join-Path $tool 'GameSdk.dll' -$previousSdkCache = $env:BD2_GAME_SDK_CACHE -try { - if (-not $env:BD2_GAME_SDK_CACHE) { $env:BD2_GAME_SDK_CACHE = Join-Path $repository '.build/game-sdk' } - Invoke-Dotnet @($toolDll, 'prepare-embedded', $assembly, $sdk, $VersionConfig) -} finally { $env:BD2_GAME_SDK_CACHE = $previousSdkCache } -$sharedSdk = [IO.File]::ReadAllText((Join-Path $sdk 'shared-sdk.txt')).Trim() -$table = Join-Path $sharedSdk 'names.json' -$runtimeProject = Join-Path $PSScriptRoot '..\GameNames\GameNames.csproj' -Invoke-Dotnet @('pack', $runtimeProject, '-c', 'Release', '--nologo', '-o', $OutputDirectory, - "-p:Version=$PackageVersion", "-p:GameNamesTable=$table", "-p:BaseIntermediateOutputPath=$runtimeObj/", "-p:OutputPath=$runtimeBin/") -Invoke-Dotnet @($toolDll, 'verify-runtime', $table, (Join-Path $runtimeBin 'BD2.GameNames.dll')) -$packageProject = Join-Path $PSScriptRoot 'Package\BD2.GameSdk.Package.csproj' -$nugetConfig = Join-Path $staging 'NuGet.Config' -$escapedSource = [Security.SecurityElement]::Escape($OutputDirectory) -[IO.File]::WriteAllText($nugetConfig, "") -Invoke-Dotnet @('restore', $packageProject, '--configfile', $nugetConfig, "-p:Version=$PackageVersion", - '-p:BD2Packaging=true', "-p:BaseIntermediateOutputPath=$packageObj/", "-p:OutputPath=$packageBin/") -Invoke-Dotnet @('pack', $packageProject, '-c', 'Release', '--no-restore', '--nologo', '-o', $OutputDirectory, - "-p:Version=$PackageVersion", "-p:BD2ToolPublishDir=$tool", '-p:BD2Packaging=true', - "-p:BaseIntermediateOutputPath=$packageObj/", "-p:OutputPath=$packageBin/") -Write-Host "Created BD2.GameNames and BD2.GameSdk $PackageVersion in $OutputDirectory" diff --git a/plugins/GameSdk/Package/BD2.GameSdk.Package.csproj b/plugins/GameSdk/Package/BD2.GameSdk.Package.csproj index 90d0574..c4d212e 100644 --- a/plugins/GameSdk/Package/BD2.GameSdk.Package.csproj +++ b/plugins/GameSdk/Package/BD2.GameSdk.Package.csproj @@ -13,13 +13,14 @@ + - - + + diff --git a/plugins/GameSdk/Program.cs b/plugins/GameSdk/Program.cs index f6a136f..1ba594c 100644 --- a/plugins/GameSdk/Program.cs +++ b/plugins/GameSdk/Program.cs @@ -19,18 +19,9 @@ internal static class Program switch (args.FirstOrDefault()) { case "prepare" when args.Length == 5: Prepare(args[1], args[2], args[3], args[4]); break; - case "prepare-embedded" when args.Length is >= 3 and <= 6: - var prepareOptions = args.Skip(3).ToList(); - string expectedGameVersion = null; - int versionOption = prepareOptions.IndexOf("--game-version"); - if (versionOption >= 0) - { - if (versionOption + 1 >= prepareOptions.Count) throw new ArgumentException("--game-version requires a version"); - expectedGameVersion = prepareOptions[versionOption + 1]; - prepareOptions.RemoveRange(versionOption, 2); - } - if (prepareOptions.Count > 1) throw new ArgumentException("Invalid prepare-embedded options"); - PrepareEmbedded(args[1], args[2], prepareOptions.FirstOrDefault(), expectedGameVersion); break; + case "prepare-embedded" when args.Length is 3 or 5: + if (args.Length == 5 && args[3] != "--game-version") throw new ArgumentException("Invalid prepare-embedded options"); + PrepareEmbedded(args[1], args[2], args.Length == 5 ? args[4] : null); break; case "export-names" when args.Length == 2: ExportNames(args[1]); break; case "names" when args.Length == 5: GenerateNames(args[1], args[2], args[3], args[4]); break; case "shell" when args.Length == 4: GenerateShell(args[1], args[2], args[3]); break; @@ -40,7 +31,7 @@ internal static class Program case "verify" when args.Length is 3 or 4: Verify(args[1], args[2], args.Length == 4 ? args[3] : null); break; case "verify-runtime" when args.Length == 3: SelfTest.VerifyRuntime(args[1], args[2]); break; case "self-test": SelfTest.Run(); break; - default: throw new ArgumentException("Usage: prepare-embedded [versions.json] | export-names | names | shell | source-navigation | verify-navigation | reobf [Assembly-CSharp.dll] [dependency-directory] | verify [Assembly-CSharp.dll] | verify-runtime | self-test"); + default: throw new ArgumentException("Usage: prepare-embedded [--game-version version] | export-names | names | shell | source-navigation | verify-navigation | reobf [Assembly-CSharp.dll] [dependency-directory] | verify [Assembly-CSharp.dll] | verify-runtime | self-test"); } return 0; } @@ -58,7 +49,7 @@ internal static class Program internal static byte[] EmbeddedNames() { - using var stream = typeof(Program).Assembly.GetManifestResourceStream("BD2.GameNames.names.json.gz") + using Stream stream = typeof(Program).Assembly.GetManifestResourceStream("BD2.GameNames.names.json.gz") ?? throw new InvalidDataException("SDK embedded names table is missing; rebuild the SDK"); using var output = new MemoryStream(); stream.CopyTo(output); @@ -71,7 +62,7 @@ internal static class Program byte[] compressed = EmbeddedNames(); File.WriteAllBytes(tablePath + ".gz", compressed); using var gzip = new GZipStream(new MemoryStream(compressed), CompressionMode.Decompress); - using var destination = File.Create(tablePath); + using FileStream destination = File.Create(tablePath); gzip.CopyTo(destination); } @@ -81,17 +72,17 @@ internal static class Program return File.Exists(pointer) ? File.ReadAllText(pointer).Trim() : Path.GetFullPath(directory); } - internal static void PrepareEmbedded(string assembly, string output, string versions = null, string expectedGameVersion = null) => - PrepareShared(EmbeddedNames(), assembly, output, versions, expectedGameVersion); + internal static void PrepareEmbedded(string assembly, string output, string expectedGameVersion = null) => + PrepareShared(EmbeddedNames(), assembly, output, expectedGameVersion); internal static void PreparePackage(string compressedTable, string assembly, string output) => - PrepareShared(File.ReadAllBytes(compressedTable), assembly, output, null, null); + PrepareShared(File.ReadAllBytes(compressedTable), assembly, output, null); - private static void PrepareShared(byte[] compressed, string assembly, string output, string versions, string expectedGameVersion) + private static void PrepareShared(byte[] compressed, string assembly, string output, string expectedGameVersion) { output = Path.GetFullPath(output); Directory.CreateDirectory(output); - using var outputLock = AcquireCacheLock(Path.Combine(output, "prepare.lock"), "Waiting for this project's game SDK preparation to finish..."); + using FileStream outputLock = AcquireCacheLock(Path.Combine(output, "prepare.lock"), "Waiting for this project's game SDK preparation to finish..."); NameTable table; using (var gzip = new GZipStream(new MemoryStream(compressed), CompressionMode.Decompress)) table = JsonSerializer.Deserialize(gzip, Json); @@ -102,12 +93,6 @@ internal static class Program throw new InvalidDataException($"Plugin requires game {expectedGameVersion}, but SDK names target game {gameVersion}. Install the matching SDK package."); if (Hash(assembly) != table.assembly_sha256) throw new InvalidDataException("Game DLL does not match SDK names for game " + gameVersion); - if (versions != null) - { - using var config = JsonDocument.Parse(File.ReadAllText(versions)); - string expected = config.RootElement.GetProperty("game_version").GetString(); - if (expected != gameVersion) throw new InvalidDataException($"Repository game_version {expected} does not match SDK embedded names {gameVersion}"); - } string cacheRoot = Environment.GetEnvironmentVariable("BD2_GAME_SDK_CACHE"); if (string.IsNullOrEmpty(cacheRoot)) cacheRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "BD2", "GameSdk", "navigation"); string tableHash = Convert.ToHexString(SHA256.HashData(compressed)).ToLowerInvariant(); @@ -120,8 +105,8 @@ internal static class Program using (AcquireCacheLock(Path.Combine(shared, "generation.lock"))) { string tablePath = Path.Combine(shared, "names.json"), ready = Path.Combine(shared, "ready.txt"); - string[] relativeFiles = { "names.json", "names.json.gz", ShellName + ".dll", ShellName + ".xml", "navigation.json", - "ref/" + ShellName + ".dll", "ref/" + ShellName + ".xml", "lib/" + ShellName + ".dll", "lib/" + ShellName + ".pdb", "lib/" + ShellName + ".xml", "lib/navigation.json" }; + string[] relativeFiles = [ "names.json", "names.json.gz", ShellName + ".dll", ShellName + ".xml", "navigation.json", + "ref/" + ShellName + ".dll", "ref/" + ShellName + ".xml", "lib/" + ShellName + ".dll", "lib/" + ShellName + ".pdb", "lib/" + ShellName + ".xml", "lib/navigation.json" ]; string[] files = relativeFiles.Select(p => Path.Combine(shared, p)).ToArray(); string StampFiles() => inputs + "|" + string.Join("|", files.Select(Hash)); if (!File.Exists(ready) || !files.All(File.Exists) || File.ReadAllText(ready) != StampFiles()) @@ -129,7 +114,7 @@ internal static class Program File.Delete(ready); File.WriteAllBytes(tablePath + ".gz", compressed); using (var gzip = new GZipStream(new MemoryStream(compressed), CompressionMode.Decompress)) - using (var destination = File.Create(tablePath)) gzip.CopyTo(destination); + using (FileStream destination = File.Create(tablePath)) gzip.CopyTo(destination); GenerateShell(tablePath, assembly, Path.Combine(shared, ShellName + ".dll")); File.WriteAllText(ready, StampFiles()); } @@ -176,11 +161,11 @@ internal static class Program private static void WriteNavigationItems(string output) { - var manifest = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(output, "navigation.json"))); + SourceNavigation.Manifest manifest = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(output, "navigation.json"))); if (!Directory.Exists(manifest.SourceRoot) || Directory.EnumerateFiles(manifest.SourceRoot, "*.cs", SearchOption.AllDirectories).Count() != manifest.Documents) SourceNavigation.RestoreSources(output); var items = new System.Xml.Linq.XElement("ItemGroup"); - foreach (var path in Directory.EnumerateFiles(manifest.SourceRoot, "*.cs", SearchOption.AllDirectories).OrderBy(p => p, StringComparer.Ordinal)) + foreach (string path in Directory.EnumerateFiles(manifest.SourceRoot, "*.cs", SearchOption.AllDirectories).OrderBy(p => p, StringComparer.Ordinal)) items.Add(new System.Xml.Linq.XElement("None", new System.Xml.Linq.XAttribute("Include", path), new System.Xml.Linq.XElement("Link", "Game Sources/" + Path.GetRelativePath(manifest.SourceRoot, path)), new System.Xml.Linq.XElement("CopyToOutputDirectory", "Never"))); @@ -235,7 +220,7 @@ internal static class Program internal static void Prepare(string assembly, string mapping, string versions, string output) { - var version = JsonDocument.Parse(File.ReadAllText(versions)).RootElement.GetProperty("game_version").GetString(); + string version = JsonDocument.Parse(File.ReadAllText(versions)).RootElement.GetProperty("game_version").GetString(); if (!Path.GetFileName(mapping).Contains(version, StringComparison.Ordinal)) throw new InvalidDataException("Mapping filename must identify game_version " + version); Directory.CreateDirectory(output); @@ -263,24 +248,24 @@ internal static class Program if (string.IsNullOrWhiteSpace(version) || !Path.GetFileName(mapping).Contains(version, StringComparison.Ordinal)) throw new InvalidDataException("Mapping filename must identify game_version " + version); var map = new Dictionary(StringComparer.Ordinal); - foreach (var raw in File.ReadLines(mapping)) + foreach (string raw in File.ReadLines(mapping)) { string line = raw.Trim().TrimStart('\ufeff'); - if (line.Length == 0 || line.StartsWith('#') || line.StartsWith("//")) continue; - var parts = line.Split('⇨'); + if (line.Length == 0 || line.StartsWith('#') || line.StartsWith("//", StringComparison.Ordinal)) continue; + string[] parts = line.Split('⇨'); if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1])) throw new InvalidDataException("Invalid mapping row: " + line); string key = parts[0].Trim(), value = parts[1].Trim(); - if (map.TryGetValue(key, out var old) && old != value) throw new InvalidDataException("Ambiguous mapping: " + key); + if (map.TryGetValue(key, out string old) && old != value) throw new InvalidDataException("Ambiguous mapping: " + key); map[key] = value; } string Translate(string name) { // Dots in explicit interface members are part of the CLR name, not a scope prefix. - if (map.TryGetValue(name, out var value)) return value.Split('/').Last(); + if (map.TryGetValue(name, out string value)) return value.Split('/').Last(); // Accessors can retain their CLR prefix while the property/event is renamed. foreach (string prefix in new[] { "get_", "set_", "add_", "remove_" }) - if (name.StartsWith(prefix) && map.TryGetValue(name[prefix.Length..], out value)) + if (name.StartsWith(prefix, StringComparison.Ordinal) && map.TryGetValue(name[prefix.Length..], out value)) { string property = value.Split('/').Last(); int dot = property.LastIndexOf('.'); @@ -288,11 +273,18 @@ internal static class Program } return name; } - using var module = ReadModule(assembly); + using ModuleDefinition module = ReadModule(assembly); if (module.Assembly.Name.Name != "Assembly-CSharp") throw new InvalidDataException("Expected Assembly-CSharp"); - var table = new NameTable { game_version = version, assembly_name = module.Assembly.Name.Name, - assembly_mvid = module.Mvid.ToString(), assembly_sha256 = Hash(assembly), mapping_sha256 = Hash(mapping), generator_sha256 = Hash(typeof(Program).Assembly.Location) }; - var definitions = Types(module).ToArray(); + var table = new NameTable + { + game_version = version, + assembly_name = module.Assembly.Name.Name, + assembly_mvid = module.Mvid.ToString(), + assembly_sha256 = Hash(assembly), + mapping_sha256 = Hash(mapping), + generator_sha256 = Hash(typeof(Program).Assembly.Location) + }; + TypeDefinition[] definitions = Types(module).ToArray(); var originalTypes = definitions.ToDictionary(t => t, TypeKey); string ReadableType(TypeDefinition t) { @@ -300,27 +292,34 @@ internal static class Program if (map.TryGetValue(t.Name, out string full)) return full.Replace('/', '+'); return string.IsNullOrEmpty(t.Namespace) ? Translate(t.Name) : t.Namespace + "." + Translate(t.Name); } - foreach (var type in definitions) + foreach (TypeDefinition type in definitions) table.types.Add(new TypeName { token = type.MetadataToken.ToInt32(), original = originalTypes[type], readable = ReadableType(type) }); if (table.types.GroupBy(t => t.readable).Any(g => g.Count() > 1)) throw new InvalidDataException("Readable type collision"); var memberKeys = new HashSet(StringComparer.Ordinal); - foreach (var type in definitions) + foreach (TypeDefinition type in definitions) { void Add(IMemberDefinition member, string kind, string signature = null, MethodDefinition method = null) { - var entry = new MemberName { token = member.MetadataToken.ToInt32(), kind = kind, - declaring_type = originalTypes[type], original = member.Name, readable = Translate(member.Name), signature = signature }; + var entry = new MemberName + { + token = member.MetadataToken.ToInt32(), + kind = kind, + declaring_type = originalTypes[type], + original = member.Name, + readable = Translate(member.Name), + signature = signature + }; if (!memberKeys.Add(originalTypes[type] + "|" + kind + "|" + entry.readable + "|" + signature)) throw new InvalidDataException("Readable member collision: " + originalTypes[type] + "." + entry.readable + " " + signature); if (method != null) - foreach (var p in method.Parameters) + foreach (ParameterDefinition p in method.Parameters) if (Translate(p.Name) != p.Name) entry.parameters.Add(new ParameterName { position = p.Index, original = p.Name, readable = Translate(p.Name) }); table.members.Add(entry); } - foreach (var m in type.Methods) Add(m, "method", Signature(m), m); - foreach (var f in type.Fields) Add(f, "field", Signature(f)); - foreach (var p in type.Properties) Add(p, "property", TypeKey(p.PropertyType) + "(" + string.Join(",", p.Parameters.Select(a => TypeKey(a.ParameterType))) + ")"); - foreach (var e in type.Events) Add(e, "event", TypeKey(e.EventType)); + foreach (MethodDefinition m in type.Methods) Add(m, "method", Signature(m), m); + foreach (FieldDefinition f in type.Fields) Add(f, "field", Signature(f)); + foreach (PropertyDefinition p in type.Properties) Add(p, "property", TypeKey(p.PropertyType) + "(" + string.Join(",", p.Parameters.Select(a => TypeKey(a.ParameterType))) + ")"); + foreach (EventDefinition e in type.Events) Add(e, "event", TypeKey(e.EventType)); } // Keep unrenamed overloads that share a readable name with a renamed member. // Otherwise runtime lookup would accidentally omit the literal overload. @@ -336,23 +335,23 @@ internal static class Program internal static void GenerateShell(string tablePath, string assembly, string shellPath) { - var table = ReadTable(tablePath); + NameTable table = ReadTable(tablePath); if (Hash(assembly) != table.assembly_sha256) throw new InvalidDataException($"Assembly-CSharp does not match SDK names for game {table.game_version}; install the matching client or update the SDK names table"); - using var module = ReadModule(assembly); - var definitions = Types(module).ToArray(); + using ModuleDefinition module = ReadModule(assembly); + TypeDefinition[] definitions = Types(module).ToArray(); // Rename references while their declaring types still have original names. Rewrite(module, table, toReadable: true); var membersByToken = table.members.ToDictionary(m => m.token); var typesByToken = table.types.ToDictionary(t => t.token); - foreach (var type in definitions) + foreach (TypeDefinition type in definitions) { - foreach (var member in type.Methods.Cast().Concat(type.Fields).Concat(type.Properties).Concat(type.Events)) + foreach (IMemberDefinition member in type.Methods.Cast().Concat(type.Fields).Concat(type.Properties).Concat(type.Events)) { - membersByToken.TryGetValue(member.MetadataToken.ToInt32(), out var entry); + membersByToken.TryGetValue(member.MetadataToken.ToInt32(), out MemberName entry); if (entry != null) member.Name = entry.readable; if (member is MethodDefinition m) { - if (entry != null) foreach (var p in entry.parameters) m.Parameters[p.position].Name = p.readable; + if (entry != null) foreach (ParameterName p in entry.parameters) m.Parameters[p.position].Name = p.readable; // Retain IL only for offline source navigation/decompilation. // ReferenceAssemblyAttribute prevents this assembly from executing. } @@ -387,7 +386,7 @@ internal static class Program { var resolver = new DefaultAssemblyResolver(); resolver.AddSearchDirectory(Path.GetDirectoryName(Path.GetFullPath(path))); - foreach (var directory in searchDirectories) resolver.AddSearchDirectory(directory); + foreach (string directory in searchDirectories) resolver.AddSearchDirectory(directory); return ModuleDefinition.ReadModule(path, new ReaderParameters { InMemory = true, AssemblyResolver = resolver }); } @@ -406,7 +405,7 @@ internal static class Program string TranslateSignature(string sig) { // Type references in signatures are tokenized, so substring collisions are avoided. - return System.Text.RegularExpressions.Regex.Replace(sig, @"[^,:()\[\]&*<>\s]+", m => readableByOriginal.TryGetValue(m.Value, out var v) ? v : m.Value); + return System.Text.RegularExpressions.Regex.Replace(sig, @"[^,:()\[\]&*<>\s]+", m => readableByOriginal.TryGetValue(m.Value, out string v) ? v : m.Value); } var members = table.members.Where(m => m.kind is "method" or "field").ToDictionary( m => (toReadable ? m.declaring_type : readableByOriginal[m.declaring_type]) + "|" + m.kind + "|" + (toReadable ? m.original : m.readable) + "|" + (toReadable ? m.signature : TranslateSignature(m.signature))); @@ -425,38 +424,38 @@ internal static class Program if (provider.HasCustomAttributes) attributes.AddRange(provider.CustomAttributes); } Attributes(module); Attributes(module.Assembly); - foreach (var t in Types(module)) + foreach (TypeDefinition t in Types(module)) { Attributes(t); - foreach (var gp in t.GenericParameters) Attributes(gp); - foreach (var i in t.Interfaces) Attributes(i); - foreach (var f in t.Fields) Attributes(f); - foreach (var p in t.Properties) Attributes(p); - foreach (var e in t.Events) Attributes(e); - foreach (var m in t.Methods) + foreach (GenericParameter gp in t.GenericParameters) Attributes(gp); + foreach (InterfaceImplementation i in t.Interfaces) Attributes(i); + foreach (FieldDefinition f in t.Fields) Attributes(f); + foreach (PropertyDefinition p in t.Properties) Attributes(p); + foreach (EventDefinition e in t.Events) Attributes(e); + foreach (MethodDefinition m in t.Methods) { Attributes(m); Attributes(m.MethodReturnType); - foreach (var p in m.Parameters) Attributes(p); - foreach (var gp in m.GenericParameters) Attributes(gp); + foreach (ParameterDefinition p in m.Parameters) Attributes(p); + foreach (GenericParameter gp in m.GenericParameters) Attributes(gp); } } - foreach (var attribute in attributes) memberRefs.Add(attribute.Constructor); - foreach (var type in Types(module)) - foreach (var method in type.Methods) + foreach (CustomAttribute attribute in attributes) memberRefs.Add(attribute.Constructor); + foreach (TypeDefinition type in Types(module)) + foreach (MethodDefinition method in type.Methods) { - foreach (var ov in method.Overrides) memberRefs.Add(ov); - if (method.HasBody) foreach (var instruction in method.Body.Instructions) - if (instruction.Operand is MemberReference mr) memberRefs.Add(mr); + foreach (MethodReference ov in method.Overrides) memberRefs.Add(ov); + if (method.HasBody) foreach (Instruction instruction in method.Body.Instructions) + if (instruction.Operand is MemberReference mr) memberRefs.Add(mr); } - foreach (var reference in memberRefs.Distinct()) + foreach (MemberReference reference in memberRefs.Distinct()) { MemberReference member = reference is GenericInstanceMethod gm ? gm.ElementMethod : reference; if (member is not MethodReference && member is not FieldReference || member is IMemberDefinition || !IsGame(member.DeclaringType)) continue; - var declaring = member.DeclaringType is GenericInstanceType gi ? gi.ElementType : member.DeclaringType; + TypeReference declaring = member.DeclaringType is GenericInstanceType gi ? gi.ElementType : member.DeclaringType; // CLR array Get/Set/Address pseudo-methods have no metadata definitions. if (declaring is ArrayType) continue; string key = TypeKey(declaring) + "|" + (member is MethodReference ? "method" : "field") + "|" + member.Name + "|" + Signature(member); - if (members.TryGetValue(key, out var entry)) member.Name = toReadable ? entry.readable : entry.original; + if (members.TryGetValue(key, out MemberName entry)) member.Name = toReadable ? entry.readable : entry.original; else if (namedMembers.Contains(key[..key.LastIndexOf('|')])) throw new InvalidDataException("Game member signature does not match the names table: " + key); } // Snapshot names before renaming a parent of a nested type. @@ -464,17 +463,17 @@ internal static class Program void Visit(TypeReference type) { if (type == null || type is GenericParameter) return; - if (type is FunctionPointerType fp) { Visit(fp.ReturnType); foreach (var p in fp.Parameters) Visit(p.ParameterType); return; } + if (type is FunctionPointerType fp) { Visit(fp.ReturnType); foreach (ParameterDefinition p in fp.Parameters) Visit(p.ParameterType); return; } if (type is TypeSpecification spec) { Visit(spec.ElementType); - if (type is GenericInstanceType gi) foreach (var a in gi.GenericArguments) Visit(a); + if (type is GenericInstanceType gi) foreach (TypeReference a in gi.GenericArguments) Visit(a); if (type is IModifierType modifier) Visit(modifier.ModifierType); return; } if (!renames.ContainsKey(type) && IsGame(type)) { - if (!types.TryGetValue(TypeKey(type), out var entry)) throw new InvalidDataException("Unmapped game type: " + TypeKey(type)); + if (!types.TryGetValue(TypeKey(type), out TypeName entry)) throw new InvalidDataException("Unmapped game type: " + TypeKey(type)); renames[type] = toReadable ? entry.readable : entry.original; } Visit(type.DeclaringType); @@ -484,21 +483,21 @@ internal static class Program Visit(argument.Type); if (argument.Value is TypeReference type) Visit(type); if (argument.Value is CustomAttributeArgument boxed) VisitArgument(boxed); - if (argument.Value is CustomAttributeArgument[] array) foreach (var item in array) VisitArgument(item); + if (argument.Value is CustomAttributeArgument[] array) foreach (CustomAttributeArgument item in array) VisitArgument(item); } - foreach (var attribute in attributes) + foreach (CustomAttribute attribute in attributes) { // Decode blobs before changing assembly scopes. System.Type arguments are typed // metadata even though the ECMA-335 blob stores assembly-qualified text. - foreach (var argument in attribute.ConstructorArguments) VisitArgument(argument); + foreach (CustomAttributeArgument argument in attribute.ConstructorArguments) VisitArgument(argument); void NamedArguments(Mono.Collections.Generic.Collection arguments, string kind) { for (int i = 0; i < arguments.Count; i++) { - var argument = arguments[i]; VisitArgument(argument.Argument); + CustomAttributeNamedArgument argument = arguments[i]; VisitArgument(argument.Argument); if (!IsGame(attribute.AttributeType)) continue; string declaring = TypeKey(attribute.AttributeType); - var entry = table.members.SingleOrDefault(m => m.kind == kind && + MemberName entry = table.members.SingleOrDefault(m => m.kind == kind && (toReadable ? m.declaring_type : readableByOriginal[m.declaring_type]) == declaring && (toReadable ? m.original : m.readable) == argument.Name); if (entry != null) arguments[i] = new CustomAttributeNamedArgument(toReadable ? entry.readable : entry.original, argument.Argument); @@ -506,43 +505,43 @@ internal static class Program } NamedArguments(attribute.Fields, "field"); NamedArguments(attribute.Properties, "property"); } - foreach (var t in module.GetTypeReferences()) Visit(t); - foreach (var member in memberRefs) + foreach (TypeReference t in module.GetTypeReferences()) Visit(t); + foreach (MemberReference member in memberRefs) { Visit(member.DeclaringType); - if (member is MethodReference m) { Visit(m.ReturnType); foreach (var p in m.Parameters) Visit(p.ParameterType); if (m is GenericInstanceMethod gm) foreach (var a in gm.GenericArguments) Visit(a); } + if (member is MethodReference m) { Visit(m.ReturnType); foreach (ParameterDefinition p in m.Parameters) Visit(p.ParameterType); if (m is GenericInstanceMethod gm) foreach (TypeReference a in gm.GenericArguments) Visit(a); } if (member is FieldReference f) Visit(f.FieldType); } - foreach (var type in Types(module)) + foreach (TypeDefinition type in Types(module)) { Visit(type.BaseType); - foreach (var i in type.Interfaces) Visit(i.InterfaceType); - foreach (var f in type.Fields) Visit(f.FieldType); - foreach (var p in type.Properties) { Visit(p.PropertyType); foreach (var a in p.Parameters) Visit(a.ParameterType); } - foreach (var e in type.Events) Visit(e.EventType); - foreach (var gp in type.GenericParameters) foreach (var c in gp.Constraints) Visit(c.ConstraintType); - foreach (var m in type.Methods) + foreach (InterfaceImplementation i in type.Interfaces) Visit(i.InterfaceType); + foreach (FieldDefinition f in type.Fields) Visit(f.FieldType); + foreach (PropertyDefinition p in type.Properties) { Visit(p.PropertyType); foreach (ParameterDefinition a in p.Parameters) Visit(a.ParameterType); } + foreach (EventDefinition e in type.Events) Visit(e.EventType); + foreach (GenericParameter gp in type.GenericParameters) foreach (GenericParameterConstraint c in gp.Constraints) Visit(c.ConstraintType); + foreach (MethodDefinition m in type.Methods) { Visit(m.ReturnType); - foreach (var p in m.Parameters) Visit(p.ParameterType); - foreach (var gp in m.GenericParameters) foreach (var c in gp.Constraints) Visit(c.ConstraintType); - if (m.HasBody) { foreach (var v in m.Body.Variables) Visit(v.VariableType); foreach (var h in m.Body.ExceptionHandlers) Visit(h.CatchType); foreach (var i in m.Body.Instructions) if (i.Operand is TypeReference t) Visit(t); } + foreach (ParameterDefinition p in m.Parameters) Visit(p.ParameterType); + foreach (GenericParameter gp in m.GenericParameters) foreach (GenericParameterConstraint c in gp.Constraints) Visit(c.ConstraintType); + if (m.HasBody) { foreach (VariableDefinition v in m.Body.Variables) Visit(v.VariableType); foreach (ExceptionHandler h in m.Body.ExceptionHandlers) Visit(h.CatchType); foreach (Instruction i in m.Body.Instructions) if (i.Operand is TypeReference t) Visit(t); } } } - foreach (var pair in renames) SetTypeName(pair.Key, pair.Value); + foreach (KeyValuePair pair in renames) SetTypeName(pair.Key, pair.Value); } internal static void Reobfuscate(string tablePath, string input, string output, string originalAssembly = null, string dependencyDirectory = null) { - var table = ReadTable(tablePath); + NameTable table = ReadTable(tablePath); var dependencies = new List { Path.GetDirectoryName(Path.GetFullPath(tablePath)), Path.GetDirectoryName(Path.GetFullPath(output)) }; if (originalAssembly != null) dependencies.Add(Path.GetDirectoryName(Path.GetFullPath(originalAssembly))); if (dependencyDirectory != null) dependencies.Add(Path.GetFullPath(dependencyDirectory)); - using var module = ReadModule(input, dependencies.ToArray()); + using ModuleDefinition module = ReadModule(input, [.. dependencies]); if (module.Assembly.Name.HasPublicKey) throw new InvalidDataException("Signed plugins require an explicit signing workflow"); VerifyStamp(module, table); Rewrite(module, table, false); - foreach (var a in module.AssemblyReferences.Where(a => a.Name == ShellName)) a.Name = table.assembly_name; + foreach (AssemblyNameReference a in module.AssemblyReferences.Where(a => a.Name == ShellName)) a.Name = table.assembly_name; // Preserve compiler PDBs in obj; rewritten runtime DLL deliberately has no stale symbols. string absoluteOutput = Path.GetFullPath(output); Directory.CreateDirectory(Path.GetDirectoryName(absoluteOutput)); @@ -559,17 +558,17 @@ internal static class Program internal static void Verify(string tablePath, string plugin, string originalAssembly = null) { - var table = ReadTable(tablePath); - using var module = originalAssembly == null ? ReadModule(plugin) : ReadModule(plugin, Path.GetDirectoryName(Path.GetFullPath(originalAssembly))); + NameTable table = ReadTable(tablePath); + using ModuleDefinition module = originalAssembly == null ? ReadModule(plugin) : ReadModule(plugin, Path.GetDirectoryName(Path.GetFullPath(originalAssembly))); if (module.AssemblyReferences.Any(a => a.Name == ShellName)) throw new InvalidDataException("Runtime DLL still references the readable shell"); VerifyStamp(module, table); if (originalAssembly != null) { if (Hash(originalAssembly) != table.assembly_sha256) throw new InvalidDataException("Verification game binary does not match the names table"); bool IsGame(TypeReference t) => t.GetElementType().Scope is AssemblyNameReference a && a.Name == table.assembly_name; - foreach (var type in module.GetTypeReferences().Where(IsGame)) + foreach (TypeReference type in module.GetTypeReferences().Where(IsGame)) if (type.Resolve() == null) throw new InvalidDataException("Unresolvable game type reference: " + type.FullName); - foreach (var member in module.GetMemberReferences().Where(m => IsGame(m.DeclaringType))) + foreach (MemberReference member in module.GetMemberReferences().Where(m => IsGame(m.DeclaringType))) { if (member.DeclaringType is ArrayType) continue; if (member is MethodReference method && method.Resolve() == null || member is FieldReference field && field.Resolve() == null) @@ -580,7 +579,7 @@ internal static class Program private static void VerifyStamp(ModuleDefinition module, NameTable table) { - var markers = module.Assembly.CustomAttributes.Where(a => a.AttributeType.FullName == "System.Reflection.AssemblyMetadataAttribute" && a.ConstructorArguments.Count == 2 && a.ConstructorArguments[0].Value as string == "BD2.GameNames").ToArray(); + CustomAttribute[] markers = module.Assembly.CustomAttributes.Where(a => a.AttributeType.FullName == "System.Reflection.AssemblyMetadataAttribute" && a.ConstructorArguments.Count == 2 && a.ConstructorArguments[0].Value as string == "BD2.GameNames").ToArray(); if (markers.Length != 1 || markers[0].ConstructorArguments[1].Value as string != Stamp(table)) throw new InvalidDataException("Missing/mismatched SDK stamp: compile with GameSdkIdentity.g.cs from this names table"); } diff --git a/plugins/GameSdk/README.md b/plugins/GameSdk/README.md index 571fda7..6fea087 100644 --- a/plugins/GameSdk/README.md +++ b/plugins/GameSdk/README.md @@ -1,34 +1,36 @@ # BD2.GameSdk +本插件不会提供任何封装的游戏 API,任何功能的 patch 等操作需要自行处理。 + 给 Brown Dust II 插件作者使用的 C# 开发包。通过标准 NuGet `PackageReference` 导入,自动完成 **生成可读引用程序集和全量源码导航 → 编译插件 → reobf → 验证运行 DLL**。源码可以使用可读类型和成员,字符串反射由配套的 `BD2.GameNames` 处理。转到定义可查看完整反编译方法体。 -## 包与版本 +## 本发布包 | NuGet 包 | 内容 | 使用方式 | | --- | --- | --- | | `BD2.GameSdk` | .NET 8 构建工具、`build/*.props/targets`、压缩名字表 | 插件项目直接引用,`PrivateAssets="all"` | | `BD2.GameNames` | `lib/netstandard2.0/BD2.GameNames.dll`、XML API 文档、同一份内嵌表 | SDK 固定依赖对应版本,自动引入;DLL 随插件部署 | -当前包版本为 `0.2.1-game.2.35.10`:`0.2.1` 是开发工具/API 的语义版本,`game.2.35.10` 指定游戏版本。采用 SemVer 的 prerelease 段,安装时明确指定版本。升级游戏后更新包并重新构建插件;相同游戏版本下的不同官方 DLL 也会因指纹不同而被拒绝。 +升级游戏后更新此包并重新构建插件;相同游戏版本下的不同官方 DLL 也会因指纹不同而被拒绝。 -包不包含游戏 DLL 或可读壳。维护者用官方映射生成名字表,提交为 `plugins/GameNames/Mappings/names.json.gz`,SDK 和运行时从同一文件嵌入,插件作者只需要对应游戏客户端和 BepInEx,无需官方映射、Python、仓库 `versions.json` 或本仓库源码。 +包不包含游戏 DLL 或可读壳。用官方映射生成名字表,提交为 `plugins/GameNames/Mappings/names.json.gz`,SDK 和运行时从同一文件嵌入,插件作者只需要对应游戏客户端和 BepInEx。 包当前由维护者提供 `.nupkg` 或 NuGet 源,**尚未发布到 nuget.org**。包内包含仓库 LICENSE,许可条款沿用项目现有授权,不声明为开源许可。 ## 导入到自己的插件项目 -前提:安装 **.NET 8 SDK**,准备与包对应的游戏目录,并在该目录安装 BepInEx。推荐 SDK-style `.csproj` 与 `netstandard2.1`;运行时库本身兼容 `.NET Standard 2.0`。旧式 `packages.config` 不受支持。 +前提:安装 **.NET 8 SDK**,准备与包对应版本的游戏目录,并在该目录安装 BepInEx。推荐 SDK-style `.csproj` 与 `netstandard2.1`;运行时库本身兼容 `.NET Standard 2.0`。旧式 `packages.config` 不受支持。 将维护者提供的两个 `.nupkg` 放在一个目录,例如 `D:\NuGet\BD2`,添加为本地 NuGet 源: ```powershell dotnet nuget add source 'D:\NuGet\BD2' --name BD2 -dotnet add MyPlugin.csproj package BD2.GameSdk --version 0.2.1-game.2.35.10 +dotnet add MyPlugin.csproj package BD2.GameSdk --version 0.2.2-game.2.35.10 ``` -Visual Studio / Rider 也可在 NuGet 包管理界面添加该源,打开“包含预发布版本”,安装指定版本。若维护者提供远程 NuGet 源,把上面的目录换成该源 URL。 +Visual Studio / Rider 也可在 NuGet 包管理界面添加该源。 -最小项目文件: +简单的一个项目文件: ```xml @@ -39,14 +41,12 @@ Visual Studio / Rider 也可在 NuGet 包管理界面添加该源,打开“包 2.35.10 - + ``` -`PrivateAssets="all"` 让构建步骤只用于当前插件项目;它不会阻止运行时 DLL 复制到输出目录。SDK 自动引入 `BD2.GameNames`,以及游戏目录中的 `BepInEx`、`0Harmony`、`UnityEngine`、`UnityEngine.CoreModule` 引用,无需手写 ``。**不要再引用真实的 `Assembly-CSharp.dll`**,游戏 API 编译引用由 SDK 提供。 +`PrivateAssets="all"` 让构建步骤只用于当前插件项目。SDK 自动引入 `BD2.GameNames`,以及游戏目录中的 `BepInEx`、`0Harmony`、`UnityEngine`、`UnityEngine.CoreModule` 引用,无需手写 ``。**不要再引用真实的 `Assembly-CSharp.dll`**,游戏 API 编译引用由 SDK 提供。 在项目旁创建仅保存在本机的 `Directory.Build.props`,并加入自己的 `.gitignore`: @@ -64,21 +64,15 @@ Visual Studio / Rider 也可在 NuGet 包管理界面添加该源,打开“包 dotnet build MyPlugin.csproj -c Release '-p:GameDir=E:\Games\BrownDustII' ``` -IDE 项目加载/设计时构建会准备可读引用;CLI 首次构建同样会自动生成。使用 SDK 不需要运行额外的生成命令。完整可复制示例在 [samples/ExamplePlugin](samples/ExamplePlugin),包含 `.csproj`、插件源码和本机配置示例。 +IDE 项目加载时构建会准备可读引用,CLI 首次构建同样会自动生成。完整示例在 [samples/ExamplePlugin](samples/ExamplePlugin)。 ## 完整源码导航 -导入包、设置 `GameDir` 后,先完成一次构建。SDK 使用 ILSpy 从匹配版本的真实游戏 DLL 生成**全部类型的可读 C# 源码**,保留方法体、私有成员、嵌套类型和编译器生成实现;同步生成 Portable PDB,将源码内嵌到 PDB。名字仍来自同一份内嵌表,没有另一份源码映射。 +导入包、设置 `GameDir` 后,先完成一次构建。SDK 会使用 ILSpy 从匹配版本的真实游戏 DLL 生成**全部类型的可读 C# 源码**,保留方法体、私有成员、嵌套类型和编译器生成实现;同步生成 Portable PDB,将源码内嵌到 PDB。 -在支持外部源码导航的 IDE 中,对 `NetworkManager.Send` 等可读 API 使用“转到定义”(Visual Studio 的 F12),即可打开完整方法体。重载、泛型、参数、属性、字段、事件和嵌套类型使用真实元数据及 PDB 定位,不靠搜索同名字符串。 +在支持外部源码导航的 IDE 中,对 `NetworkManager.Send` 等可读 API 使用“转到定义”(例如 Visual Studio 的 F12),即可打开完整方法体。重载、泛型、参数、属性、字段、事件和嵌套类型使用真实元数据及 PDB 定位。 -- **Visual Studio 2022**:在“工具 → 选项 → 文本编辑器 → C# → 高级”中启用“导航到 Source Link 和嵌入源”(不同语言/版本的名称可能略有差异)。如果仍打开旧的签名视图,关闭原外部源码页并重新加载项目后再 F12。只阅读源码不需要关闭“仅我的代码”调试设置。 -- **Rider**:启用外部代码的源文件/PDB 或反编译导航。可读引用保留完整 IL,IDE 也可直接反编译出方法体;本项目的自动测试验证了 Visual Studio 的 Roslyn PDB 导航引擎,尚未手动验证 Rider 各版本界面。 -- 项目中还会链接一个 **`Game Sources`** 文件夹,支持直接浏览和文本搜索。首次生成后若文件夹未出现,重新加载项目使生成的 MSBuild 导入生效。这些 `.cs` 是 `None` 项,**不参与插件编译,也不复制到部署目录**。 - -普通视图恢复 `async/await`、迭代器和 lambda,便于阅读;`generated` 视图补全被这些语法隐藏的状态机、访问器等,`metadata` 视图补全其他隐藏声明。无法作为 C# 标识符的 CLR 生成名在源码中显示为 `__generated_...`,`navigation.json` 和名字表仍保留真实元数据身份。 - -首次全量生成会花费数分钟,日志持续报告已处理的类型数。本机 2.35.10 的一次验证生成了 **24,856 个源码文件、654,265 个声明、319,350 个带方法体的符号**。生成结果按名字表、生成器及其依赖、全部游戏 Managed DLL 的指纹缓存,同一台机器上的插件项目共用;并行构建会等待同一个缓存生成完成。后续构建不再执行全量反编译。 +首次全量生成会花费数分钟,且体积稍微有些许庞大。本机 2.35.10 的一次验证生成了 **24,856 个源码文件、654,265 个声明、319,350 个带方法体的符号**。生成结果按名字表、生成器及其依赖、全部游戏 Managed DLL 的指纹缓存,同一台机器上的插件项目共用。 所有依赖 SDK 的插件必须在自己的 `.csproj` 明确声明目标**游戏版本**: @@ -86,60 +80,13 @@ IDE 项目加载/设计时构建会准备可读引用;CLI 首次构建同样 2.35.10 ``` -这个值与 SDK/API 工具版本(如 `0.2.1`)不同。构建核对声明值、SDK 内嵌表、真实游戏 DLL 指纹,以及仓库 `versions.json`(仓库插件)。缺少声明或版本不匹配会报错;不会自动选用其他版本。NuGet 项目仍需安装带对应游戏版本的包。 +构建核对声明值、SDK 内嵌表、真实游戏 DLL 指纹。缺少声明或版本不匹配会报错;不会自动选用其他版本。NuGet 项目仍需安装带对应游戏版本的包。 -共享目录按 `<缓存根>/<游戏版本>/<内容指纹>/` 存放。同版本的三个插件、Debug/Release、NuGet 项目在输入一致时直接引用同一份产物;内容指纹用于隔离同一游戏版本内不同 SDK 实现或 Managed DLL,避免覆盖正在使用的旧引用。 +仓库默认缓存根为 `.build/game-sdk`,与源码位于同一盘。第三方 NuGet 项目默认使用 `%LOCALAPPDATA%\BD2\GameSdk\navigation`;可在本机 `Directory.Build.props` 设置 `BD2GameSdkCache`,或设置环境变量 `BD2_GAME_SDK_CACHE`。 `obj` 不影响共享缓存。缺失的共享源码可在下次准备时从 PDB 恢复,只有删除共享缓存才会触发重新生成。 -仓库默认缓存根为 `.build/game-sdk`,与源码位于同一盘。第三方 NuGet 项目默认使用 `%LOCALAPPDATA%\BD2\GameSdk\navigation`;可在本机 `Directory.Build.props` 设置 `BD2GameSdkCache`,或设置环境变量 `BD2_GAME_SDK_CACHE`。删除插件 `obj` 不影响共享缓存。缺失的共享源码可在下次准备时从 PDB 恢复;删除共享缓存才会触发重新生成。 +`lib` 中的程序集仅供开发导航,**不要部署或执行**。这里展示的是当前 DLL 的反编译源码,局部变量名和语法可能与原始工程不同。导航 PDB 对应可读程序集,不能用于真实混淆游戏 DLL 的逐行调试。 -共享目录包含如下产物,**每个插件 obj 不再复制这些大文件**: - -| 路径 | 用途 | -| --- | --- | -| `ref/Assembly-CSharp.Readable.dll` + XML | 编译器引用,带引用程序集标记,禁止执行 | -| `lib/Assembly-CSharp.Readable.dll` + `.pdb` | IDE 对应实现和内嵌源码符号 | -| `lib/sources` | 完整可读源码,供项目浏览与搜索 | -| `names.json` + `.gz` | 同一名字表的缓存导出,供 reobf 使用 | -| `navigation.json` | 全量 token 索引;schema 2 路径相对于 `SourceRoot` | -| `GameSourceNavigation.props` | 共享源码的 MSBuild 文件列表 | - -每个插件 `obj/<配置>/<框架>/game-sdk`(NuGet 为 `bd2-game-sdk`)只保留小型 `GameSdkIdentity.g.cs`、`shared-sdk.txt`、导航导入 `.props` 和锁文件。首次构建通过生成的指针设置引用路径,后续 IDE 加载直接导入共享配置;`Game Sources` 仍可浏览搜索。旧布局的大文件副本在成功准备后自动清理。 - -`lib` 中的程序集仅供开发导航,**不要部署或执行**。这里展示的是当前 DLL 的反编译源码,局部变量名和语法可能与原始工程不同。导航 PDB 对应可读程序集,不能用于真实混淆游戏 DLL 的逐行调试。所有开发产物都不会复制到游戏部署目录。 - -## 插件代码 - -```csharp -using System; -using BD2.GameNames; -using BepInEx; -using HarmonyLib; - -[BepInPlugin("example.my-plugin", "My Plugin", "1.0.0")] -public sealed class MyPlugin : BaseUnityPlugin -{ - private void Awake() - { - try - { - Game.Validate(typeof(MyPlugin).Assembly, - message => Logger.LogInfo(message)); - - var target = Game.Method(ui => ui.SendMaintenanceInfo(false)); - new Harmony("example.my-plugin").Patch(target, - prefix: new HarmonyMethod(typeof(MyPlugin), nameof(BeforeMaintenance))); - } - catch (Exception exception) - { - Logger.LogError("My Plugin initialization failed: " + exception); - } - } - - private static void BeforeMaintenance() { } -} -``` - -表达式只取得 `MethodInfo`,不会执行游戏调用;签名由 C# 编译器检查。已知游戏类型优先使用 `typeof`,公开成员优先使用强类型表达式或 `nameof`,可以获得 IDE 补全和编译检查。实际公开调用和字段访问也能直接使用可读名字。私有成员保持原访问性,通过已知类型和映射反射 API 查询: +公开方法调用、字段和属性访问可以直接使用可读名字,获得 IDE 补全和编译检查。需要通过反射或 Harmony 定位成员时,已知游戏类型优先使用 `typeof(GameType)`,避免用字符串查找类型,可访问的成员可以使用 `nameof` 提供经过编译检查的名字,或者通过强类型表达式取得 `MethodInfo`,让编译器检查所选重载及参数类型。表达式只用于取得方法信息,不执行方法。私有成员保留原访问性,通过已知类型和映射反射 API 查询,例如: ```csharp using System.Reflection; @@ -160,15 +107,13 @@ var maintenance = typeof(IntroUI).GetGameMethod(nameof(IntroUI.SendMaintenanceIn owner.StartCoroutine(Game.MemberName(owner.GetType(), "ReadableCoroutineName")); ``` -`GetGameMethod/GetGameField/GetGameProperty/GetGameEvent` 采用 .NET 反射约定:查不到返回 null,重载歧义抛出 `AmbiguousMatchException`,null 名字参数抛出 `ArgumentNullException`。`MemberName` 的成员种类使用 `GameMemberKind` 枚举。未知映射保留字面名;普通 UI 文案、`nameof` 字符串和协程常量都不会被 reobf 自动替换。 +`GetGameMethod/GetGameField/GetGameProperty/GetGameEvent` 采用 .NET 反射约定:查不到返回 null,重载歧义抛出 `AmbiguousMatchException`,null 名字参数抛出 `ArgumentNullException`。`MemberName` 的成员种类使用 `GameMemberKind` 枚举。未知映射保留字面名,普通 UI 文案、`nameof` 字符串和协程常量都不会被 reobf 自动替换。 -`Game.FindType` 保留给运行时才知道类型名称的查询。只做字符串反射的项目可单独安装 `BD2.GameNames`,启动时用 `Game.ValidateGame(...)`。这种方式没有可读游戏类型引用,也没有编译指纹;使用 SDK 的项目必须用 `Game.Validate(typeof(MyPlugin).Assembly, ...)`。 +`Game.FindType` 保留给运行时才知道类型名称的查询。只做字符串反射的项目可单独安装 `BD2.GameNames`,启动时用 `Game.ValidateGame(...)`。这种方式没有可读游戏类型引用,也没有编译指纹,使用 SDK 的项目必须用 `Game.Validate(typeof(MyPlugin).Assembly, ...)`。 ## 构建与部署 -构建成功后,将 `bin/Release/netstandard2.1/MyPlugin.dll` 和相邻的 **`BD2.GameNames.dll`** 安装到游戏 `BepInEx/plugins`。客户端只安装一份共享库;多个插件应使用同一游戏表。不要部署 `obj`、可读壳、源码、导航 PDB、`GameSdk.dll`、`Mono.Cecil.dll`、`ICSharpCode.Decompiler.dll` 或其他构建工具。 - -最终插件 DLL 已回映射,`obj` 保留可读编译产物和 PDB,最终目录删除改写前的旧 PDB。SDK 包是构建依赖;玩家电脑不需要 .NET 8 SDK 或 NuGet。这里支持 Unity 的 Mono/Managed 客户端,要求存在真实 `Assembly-CSharp.dll`;IL2CPP/AOT 客户端不受支持。 +构建成功后,将你的插件产物和相邻的 **`BD2.GameNames.dll`** 安装到游戏 `BepInEx/plugins`。当然,客户端只需要安装一个`BD2.GameNames.dll`,`GameSdk.dll`只是编写时需要。 | MSBuild 属性 | 用途 | | --- | --- | @@ -180,79 +125,3 @@ owner.StartCoroutine(Game.MemberName(owner.GetType(), "ReadableCoroutineName")); | `BD2AddBepInExReferences=false` | 使用其他宿主或自己提供 BepInEx/Harmony 引用时关闭自动引用 | | `BD2AddUnityReferences=false` | 自己提供 Unity 引用时关闭自动引用 | | `BD2GameSdkEnabled=false` | 暂时关闭壳生成与 reobf;只适用于不依赖可读游戏 API 的项目 | - -额外 Unity 模块或第三方游戏依赖仍以普通 `` 添加,并设 `false`。强签名插件需要重新签名流程,当前 reobf 明确拒绝。CI 用相同的 PackageReference 和 `GameDir` 参数构建,直接分发 Build 的 DLL 产物;不要把原始 `obj` DLL 放进发布包。 - -## 维护者打包 - -在本仓库运行: - -```powershell -.\plugins\GameSdk\Pack.ps1 ` - -GameDir '<当前客户端目录>' -``` - -默认生成 `.build/nuget/BD2.GameSdk.<版本>.nupkg` 和 `BD2.GameNames.<版本>.nupkg`。工具版本来自 `plugins/PackageMetadata.props`,游戏版本来自根目录 `versions.json`,组合成 `工具版本-game.游戏版本`。可传 `-PackageVersion` 和 `-OutputDirectory`;发布后的同一包版本必须保持内容不可变,有任何变更都递增工具版本。 - -SDK 精确依赖同版本运行时包;两个包中的压缩表都直接来自同一生成结果。工具通过 `dotnet publish` 打包自带 Mono.Cecil,不把 Cecil 作为插件的 NuGet 依赖。包内携带 README、LICENSE、仓库地址和作者信息。本脚本只生成本地包,不上传到任何 NuGet 服务。 - -工具包也包含 Mono.Cecil、ILSpy 和相关 .NET 组件的 MIT 授权原文 `THIRD-PARTY-NOTICES.txt`;第三方组件保持其自身许可。打包后运行 `VerifyPackages.ps1 -GameDir '<客户端目录>'`,它在仓库外的全新目录和 NuGet 缓存中构建示例插件,检查运行时复制、禁止部署的文件、回映射引用、完整内嵌表、源码/PDB 覆盖和重复构建。 - -打包工程在普通 IDE 加载/源码构建时直接引用 `GameNames.csproj`,无需先生成包,也不会到 nuget.org 查找未发布的 `BD2.GameNames`。`Pack.ps1` 用 `BD2Packaging=true` 切换到同版本的精确 NuGet 依赖,并把打包用 `obj/bin` 放在独立 staging 目录,避免 IDE restore 与 pack 互相覆盖依赖资产。不要直接对打包 `.csproj` 执行 `dotnet pack`,使用脚本才能保证两个包版本配套。 - -仓库中的 `samples/ExamplePlugin/NuGet.Config` 指向 `.build/nuget`,示例保持真正的 NuGet 消费方式;先运行 `Pack.ps1` 再构建示例。复制示例到其他目录时按前文配置自己的包源,不要照搬仓库相对路径。 - -## 仓库源码构建与内嵌表 - -仓库的三个插件与 NuGet 消费项目采用同样的内嵌表流程。开发启动命令保持不变: - -公共配置见 `plugins/Directory.Build.props`,本机游戏安装位置见不提交的 `plugins/Directory.Build.local.props`。可从 `Directory.Build.local.props.example` 复制并设置 `BD2LocalGameDir`、`BD2CaptureGameDir`、可选的 `BD2GameSdkCache`。配置和目标框架确定后,由 `Directory.Build.targets` 计算中间目录并导入 SDK/版本构建步骤。 - -仓库插件通过普通 `ProjectReference` 使用共享运行时;IDE 设计时加载不会嵌套构建三份运行时。SDK 准备过程按输出目录加锁,导航 `.props` 只在内容改变时原子替换,重复设计时构建不会因重写该文件触发连续项目加载。设计时构建不执行 reobf。 - -```powershell -# 从 go 目录运行;游戏目录来自 go/config.json -go run .\cmd\bd2client --dev run -``` - -直接构建源码插件也只需要游戏目录: - -```powershell -dotnet build plugins/LocalIdentity/LocalIdentity.csproj -c Release '-p:GameDir=<客户端目录>' -``` - -构建工具把表和可读引用生成到按游戏版本分组的共享缓存,插件 `obj` 只保存该目录的指针,再使用共享表做回映射。`GameSdk` 和 `BD2.GameNames` 的唯一名字数据源是仓库内的 `GameNames/Mappings/names.json.gz`。两份程序集内嵌的是同一份压缩字节,不维护第二份映射;共享目录里的表只是可以删除重建的缓存。NuGet 包同样不再包含单独的 `tools/data` 表文件。 - -表包含 `game_version`、完整类型名、成员声明类型/签名/metadata token、参数映射、真实 DLL 的 MVID/SHA-256 和官方映射 SHA-256。构建时先验证 DLL 指纹,仓库构建还核对 `versions.json`;不匹配会要求更新 SDK,不会尝试使用其他版本或猜名字。插件启动时核对表指纹、游戏 DLL 和少量已知条目。 - -## 更新游戏版本的名字表 - -只有维护者更新 SDK 名字数据时需要官方 `.obfuscate`。先更新仓库 `versions.json`,然后执行: - -```powershell -.\plugins\GameSdk\UpdateNames.ps1 ` - -GameDir '<新版本客户端目录>' ` - -GameMapping '' -``` - -脚本从官方映射和真实元数据生成表,完成全量壳转换验证后才替换 `plugins/GameNames/Mappings/names.json.gz`。提交这份表与对应版本变更;重新构建插件并发布新版本配套包。更新过程中产生的明文表和壳留在 `.build/names-update`,不作为源码提交。 - -`tools/python/deobfuscate_client_source.py` 继续生成阅读镜像;程序集映射使用这里的元数据名字表,保留作用域、泛型位置和重载签名。官方映射冲突或可读签名冲突会报错。 - -工具也提供独立命令: - -```powershell -dotnet build plugins/GameSdk/GameSdk.csproj -c Release -$tool = 'plugins/GameSdk/bin/Release/net8.0/GameSdk.dll' -dotnet $tool prepare-embedded '' '<插件 obj 指针目录>' --game-version 2.35.10 -dotnet $tool export-names '<导出的 names.json>' -dotnet $tool reobf '' '<可读插件.dll>' '<运行插件.dll>' '' '' -dotnet $tool verify '' '<运行插件.dll>' '' -dotnet $tool verify-runtime '' '' -dotnet $tool verify-navigation '<生成的 SDK 目录>' -dotnet $tool self-test -``` - -手动编译通过 `shared-sdk.txt` 找到共享目录,引用其中 `ref/Assembly-CSharp.Readable.dll`,并编译指针目录中的 `GameSdkIdentity.g.cs`;共享目录相邻 `lib` 供 IDE 查找。`verify-navigation` 全量检查 PE/PDB 身份、内嵌/本地源码校验和、类型文档和全部方法体的符号。 - -`self-test` 生成合成游戏 DLL,验证重载、泛型、继承、嵌套/编译器生成类型、私有成员、事件、参数、表达式、字符串不变和版本拒绝。`.build/game-sdk-tests` 仅保存每次自测的临时产物,不参与 SDK 构建、源码导航或客户端启动,用完可以删除;下次自测会重新生成。`VerifyPackages.ps1` 验证仓库外 NuGet 项目及完整源码/PDB 覆盖。实际 Unity/Harmony 行为需在游戏启动后检查日志。 diff --git a/plugins/GameSdk/SelfTest.cs b/plugins/GameSdk/SelfTest.cs index 3d54d01..1183ad8 100644 --- a/plugins/GameSdk/SelfTest.cs +++ b/plugins/GameSdk/SelfTest.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Diagnostics; using System.Runtime.Loader; using Mono.Cecil; @@ -8,6 +9,7 @@ namespace BD2.GameSdk; /// End-to-end tests with synthetic binaries. Never execute the production game or readable shell. internal static class SelfTest { + private static readonly string[] PublicRuntimeTypes = ["BD2.GameNames.Game", "BD2.GameNames.GameMemberKind"]; private static void Assert(bool condition, string message) { if (!condition) throw new InvalidOperationException("Self-test: " + message); @@ -17,7 +19,7 @@ internal static class SelfTest var start = new ProcessStartInfo("dotnet") { RedirectStandardOutput = true, RedirectStandardError = true }; foreach (string arg in new[] { "build", project, "--nologo", "-c", "Release" }.Concat(properties)) start.ArgumentList.Add(arg); using var process = Process.Start(start); - var stdout = process.StandardOutput.ReadToEndAsync(); var stderr = process.StandardError.ReadToEndAsync(); + Task stdout = process.StandardOutput.ReadToEndAsync(); Task stderr = process.StandardError.ReadToEndAsync(); process.WaitForExit(); string output = stdout.GetAwaiter().GetResult() + stderr.GetAwaiter().GetResult(); if (process.ExitCode != 0) throw new InvalidOperationException(output); @@ -72,22 +74,22 @@ internal static class SelfTest string mappingPath = Path.Combine(root, "ObfuscationTranslation_9.8.7.obfuscate"), versionPath = Path.Combine(root, "versions.json"); File.WriteAllText(versionPath, "{\"game_version\":\"9.8.7\"}"); var rows = new List { "#ReverseOrder", "#Classes", "#Methods", "#Fields", "#Properties", "#Events", "#Parameters" }; - using (var game = Program.ReadModule(gamePath)) + using (ModuleDefinition game = Program.ReadModule(gamePath)) { var table = new BD2.GameNames.Internal.NameTable { assembly_name = "Assembly-CSharp" }; - var defs = game.GetTypes().ToArray(); int counter = 0; - string Obfuscated() => "ὠ" + string.Concat((counter++).ToString().Select(c => (char)('ὠ' + c - '0'))); + TypeDefinition[] defs = game.GetTypes().ToArray(); int counter = 0; + string Obfuscated() => "ὠ" + string.Concat((counter++).ToString(CultureInfo.InvariantCulture).Select(c => (char)('ὠ' + c - '0'))); var newNames = new Dictionary(); - string Full(TypeDefinition t) => t.DeclaringType != null ? Full(t.DeclaringType) + "+" + newNames[t] : newNames.TryGetValue(t, out var n) ? n : Program.TypeKey(t); - foreach (var t in defs) + string Full(TypeDefinition t) => t.DeclaringType != null ? Full(t.DeclaringType) + "+" + newNames[t] : newNames.TryGetValue(t, out string n) ? n : Program.TypeKey(t); + foreach (TypeDefinition t in defs) if (t.Namespace == "Readable" || t.DeclaringType?.Namespace == "Readable") { newNames[t] = Obfuscated(); string meaning = t.DeclaringType == null ? Program.TypeKey(t) : newNames[t.DeclaringType] + "/" + (t.Name == "State" ? "d__0" : t.Name); rows.Add(newNames[t] + "⇨" + meaning); } - foreach (var t in defs) table.types.Add(new BD2.GameNames.Internal.TypeName { token = t.MetadataToken.ToInt32(), original = Program.TypeKey(t), readable = Full(t) }); - foreach (var t in defs) + foreach (TypeDefinition t in defs) table.types.Add(new BD2.GameNames.Internal.TypeName { token = t.MetadataToken.ToInt32(), original = Program.TypeKey(t), readable = Full(t) }); + foreach (TypeDefinition t in defs) { void Rename(IMemberDefinition member, string kind, string signature = null, MethodDefinition method = null) { @@ -95,31 +97,31 @@ internal static class SelfTest string original = member.Name, target = renamed ? Obfuscated() : original; if (renamed) rows.Add(target + "⇨" + original); var entry = new BD2.GameNames.Internal.MemberName { token = member.MetadataToken.ToInt32(), declaring_type = Program.TypeKey(t), original = original, readable = target, kind = kind, signature = signature }; - if (method != null) foreach (var p in method.Parameters) - { - if (string.IsNullOrEmpty(p.Name)) continue; - string name = Obfuscated(); rows.Add(name + "⇨" + p.Name); - entry.parameters.Add(new BD2.GameNames.Internal.ParameterName { position = p.Index, original = p.Name, readable = name }); - } + if (method != null) foreach (ParameterDefinition p in method.Parameters) + { + if (string.IsNullOrEmpty(p.Name)) continue; + string name = Obfuscated(); rows.Add(name + "⇨" + p.Name); + entry.parameters.Add(new BD2.GameNames.Internal.ParameterName { position = p.Index, original = p.Name, readable = name }); + } table.members.Add(entry); } - foreach (var m in t.Methods) Rename(m, "method", Program.Signature(m), m); - foreach (var f in t.Fields) Rename(f, "field", Program.Signature(f)); - foreach (var p in t.Properties) Rename(p, "property"); - foreach (var e in t.Events) Rename(e, "event", Program.TypeKey(e.EventType)); + foreach (MethodDefinition m in t.Methods) Rename(m, "method", Program.Signature(m), m); + foreach (FieldDefinition f in t.Fields) Rename(f, "field", Program.Signature(f)); + foreach (PropertyDefinition p in t.Properties) Rename(p, "property"); + foreach (EventDefinition e in t.Events) Rename(e, "event", Program.TypeKey(e.EventType)); } Program.Rewrite(game, table, true); var entries = table.members.ToDictionary(m => m.token); - foreach (var t in defs) + foreach (TypeDefinition t in defs) { - foreach (var m in t.Methods) + foreach (MethodDefinition m in t.Methods) { - var entry = entries[m.MetadataToken.ToInt32()]; m.Name = entry.readable; - foreach (var p in entry.parameters) m.Parameters[p.position].Name = p.readable; + GameNames.Internal.MemberName entry = entries[m.MetadataToken.ToInt32()]; m.Name = entry.readable; + foreach (GameNames.Internal.ParameterName p in entry.parameters) m.Parameters[p.position].Name = p.readable; } - foreach (var f in t.Fields) f.Name = entries[f.MetadataToken.ToInt32()].readable; - foreach (var p in t.Properties) p.Name = entries[p.MetadataToken.ToInt32()].readable; - foreach (var e in t.Events) e.Name = entries[e.MetadataToken.ToInt32()].readable; + foreach (FieldDefinition f in t.Fields) f.Name = entries[f.MetadataToken.ToInt32()].readable; + foreach (PropertyDefinition p in t.Properties) p.Name = entries[p.MetadataToken.ToInt32()].readable; + foreach (EventDefinition e in t.Events) e.Name = entries[e.MetadataToken.ToInt32()].readable; Program.SetTypeName(t, table.types.Single(e => e.token == t.MetadataToken.ToInt32()).readable); } game.Write(gamePath); @@ -132,9 +134,9 @@ internal static class SelfTest Program.Prepare(gamePath, mappingPath, versionPath, sdkDir); SourceNavigation.Verify(sdkDir); string tablePath = Path.Combine(sdkDir, "names.json"); - var generated = Program.ReadTable(tablePath); + GameNames.Internal.NameTable generated = Program.ReadTable(tablePath); Assert(generated.types.Any(t => t.readable == "Readable.Agent+d__0"), "compiler-generated scoped type"); - using (var shell = Program.ReadModule(Path.Combine(sdkDir, Program.ShellName + ".dll"))) + using (ModuleDefinition shell = Program.ReadModule(Path.Combine(sdkDir, Program.ShellName + ".dll"))) { Assert(shell.Assembly.CustomAttributes.Any(a => a.AttributeType.Name == "ReferenceAssemblyAttribute"), "reference assembly marker"); Assert(shell.GetType("Readable.Agent").Methods.First(m => m.Name == "Ping").Body.Instructions.Any(i => i.OpCode == OpCodes.Ret), "readable IL retained for offline decompiler fallback"); @@ -206,7 +208,7 @@ internal static class SelfTest """); Build(probeProject); string readablePlugin = Path.Combine(probeDir, "bin/Release/netstandard2.1/Probe.dll"), pluginPath = Path.Combine(root, "Probe.dll"); - using (var plugin = Program.ReadModule(readablePlugin)) + using (ModuleDefinition plugin = Program.ReadModule(readablePlugin)) { var foreign = new TypeReference("Readable", "Agent", plugin, new AssemblyNameReference("Foreign", new Version(1, 0))); var holder = new TypeDefinition("", "ExternalHolder", TypeAttributes.Public, plugin.TypeSystem.Object); @@ -214,7 +216,7 @@ internal static class SelfTest plugin.Write(readablePlugin); } Program.Reobfuscate(tablePath, readablePlugin, pluginPath, gamePath); - using (var rewritten = Program.ReadModule(pluginPath)) + using (ModuleDefinition rewritten = Program.ReadModule(pluginPath)) { Assert(rewritten.GetType("ExternalHolder").Fields[0].FieldType.FullName == "Readable.Agent", "external assembly scope isolation"); Assert(!rewritten.AssemblyReferences.Any(a => a.Name == "System.Private.CoreLib"), "target framework preserved"); @@ -245,13 +247,13 @@ internal static class SelfTest } private static string[] Strings(string path) { - using var module = Program.ReadModule(path); + using ModuleDefinition module = Program.ReadModule(path); return module.GetTypes().SelectMany(t => t.Methods).Where(m => m.HasBody).SelectMany(m => m.Body.Instructions).Where(i => i.OpCode == OpCodes.Ldstr).Select(i => (string)i.Operand).ToArray(); } internal static void VerifyRuntime(string tablePath, string runtimePath) { - var table = Program.ReadTable(tablePath); - using (var runtime = Program.ReadModule(runtimePath)) + GameNames.Internal.NameTable table = Program.ReadTable(tablePath); + using (ModuleDefinition runtime = Program.ReadModule(runtimePath)) { var resource = (EmbeddedResource)runtime.Resources.Single(r => r.Name == "BD2.GameNames.names.json.gz"); Assert(resource.GetResourceData().SequenceEqual(File.ReadAllBytes(tablePath + ".gz")), "embedded runtime table equals the shared table"); @@ -259,12 +261,12 @@ internal static class SelfTest var context = new AssemblyLoadContext("BD2 full runtime-table check", isCollectible: true); try { - var runtime = context.LoadFromAssemblyPath(Path.GetFullPath(runtimePath)); - Assert(runtime.GetExportedTypes().Select(t => t.FullName).OrderBy(n => n).SequenceEqual(new[] { "BD2.GameNames.Game", "BD2.GameNames.GameMemberKind" }), "serialization models are not public API"); - var game = runtime.GetType("BD2.GameNames.Game"); + System.Reflection.Assembly runtime = context.LoadFromAssemblyPath(Path.GetFullPath(runtimePath)); + Assert(runtime.GetExportedTypes().Select(t => t.FullName).OrderBy(n => n).SequenceEqual(PublicRuntimeTypes), "serialization models are not public API"); + Type game = runtime.GetType("BD2.GameNames.Game"); Assert((string)game.GetProperty("GameVersion").GetValue(null) == table.game_version, "runtime deserializes the full table"); - var translate = game.GetMethod("TypeName").CreateDelegate>(); - foreach (var type in table.types) Assert(translate(type.readable) == type.original, "runtime type translation: " + type.readable); + Func translate = game.GetMethod("TypeName").CreateDelegate>(); + foreach (GameNames.Internal.TypeName type in table.types) Assert(translate(type.readable) == type.original, "runtime type translation: " + type.readable); Assert(translate("Unknown.Type") == "Unknown.Type", "runtime literal fallback"); Console.WriteLine($"Verified embedded runtime table: game={table.game_version}, {table.types.Count} type lookups; {runtimePath}"); } diff --git a/plugins/GameSdk/SourceNavigation.cs b/plugins/GameSdk/SourceNavigation.cs index 1ba4350..f47bae0 100644 --- a/plugins/GameSdk/SourceNavigation.cs +++ b/plugins/GameSdk/SourceNavigation.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Collections.Concurrent; using System.Collections.Immutable; using System.Diagnostics; @@ -42,10 +43,10 @@ internal static class SourceNavigation string directory = Path.GetDirectoryName(Path.GetFullPath(shellPath)); string sourceRoot = Path.Combine(directory, "sources"); Directory.CreateDirectory(sourceRoot); - using var module = Program.ReadModule(shellPath, dependencyDirectory); + using ModuleDefinition module = Program.ReadModule(shellPath, dependencyDirectory); using var pe = new PEFile(shellPath, new MemoryStream(File.ReadAllBytes(shellPath)), PEStreamOptions.PrefetchEntireImage); - var reader = pe.Metadata; - var types = reader.TypeDefinitions.Where(h => reader.GetTypeDefinition(h).GetDeclaringType().IsNil && reader.GetString(reader.GetTypeDefinition(h).Name) != "").ToArray(); + MetadataReader reader = pe.Metadata; + TypeDefinitionHandle[] types = reader.TypeDefinitions.Where(h => reader.GetTypeDefinition(h).GetDeclaringType().IsNil && reader.GetString(reader.GetTypeDefinition(h).Name) != "").ToArray(); var sources = new ConcurrentBag(); var timer = Stopwatch.StartNew(); int completed = 0; @@ -60,10 +61,10 @@ internal static class SourceNavigation { int token = MetadataTokens.GetToken(handle); string relative = PathFor(reader, handle); - var primary = worker.Decompile(handle, Path.Combine(sourceRoot, relative), raw: false); + Source primary = worker.Decompile(handle, Path.Combine(sourceRoot, relative), raw: false); ApplyImplicitDeclarations(primary.Declarations, definitionsByToken[MetadataTokens.GetToken(handle)]); sources.Add(primary); - var typeTokens = Descendants(reader, handle).Select(h => MetadataTokens.GetToken(h)).ToArray(); + int[] typeTokens = Descendants(reader, handle).Select(h => MetadataTokens.GetToken(h)).ToArray(); var wanted = typeTokens.SelectMany(t => expected[t]).ToHashSet(); var covered = primary.Declarations.Keys.ToHashSet(); if (wanted.Except(covered).Any()) @@ -73,7 +74,7 @@ internal static class SourceNavigation // Roslyn materializes embedded documents by basename, so primary // and generated views must have distinct filenames as well as paths. string generatedPath = Path.Combine(Path.GetDirectoryName(relative), Path.GetFileNameWithoutExtension(relative) + ".generated.cs"); - var raw = worker.Decompile(handle, Path.Combine(sourceRoot, "generated", generatedPath), raw: true); + Source raw = worker.Decompile(handle, Path.Combine(sourceRoot, "generated", generatedPath), raw: true); ApplyImplicitDeclarations(raw.Declarations, definitionsByToken[MetadataTokens.GetToken(handle)]); sources.Add(raw); covered.UnionWith(raw.Declarations.Keys); @@ -82,8 +83,8 @@ internal static class SourceNavigation { // ILSpy hides some runtime-only definitions even with transformations // disabled. Decompile them explicitly, retaining exact token identity. - var supplemental = worker.DecompileMember(MetadataTokens.EntityHandle(missing), - Path.Combine(sourceRoot, "metadata", missing.ToString("X8") + ".cs")); + Source supplemental = worker.DecompileMember(MetadataTokens.EntityHandle(missing), + Path.Combine(sourceRoot, "metadata", missing.ToString("X8", CultureInfo.InvariantCulture) + ".cs")); sources.Add(supplemental); if (!supplemental.Declarations.ContainsKey(missing)) throw new InvalidDataException($"Decompiler omitted declaration 0x{missing:X8}; complete navigation cannot be published"); @@ -94,50 +95,50 @@ internal static class SourceNavigation return worker; }, worker => worker.Dispose()); - var ordered = sources.OrderBy(s => s.Path, StringComparer.Ordinal).ToArray(); + Source[] ordered = [.. sources.OrderBy(s => s.Path, StringComparer.Ordinal)]; var sourceByPath = ordered.ToDictionary(s => s.Path, StringComparer.Ordinal); var declarations = new Dictionary(); var methodPoints = new Dictionary Points)>(); - foreach (var source in ordered.OrderBy(s => s.Declarations.Values.FirstOrDefault()?.GeneratedView == true)) + foreach (Source source in ordered.OrderBy(s => s.Declarations.Values.FirstOrDefault()?.GeneratedView == true)) { - foreach (var declaration in source.Declarations) declarations.TryAdd(declaration.Key, declaration.Value); - foreach (var method in source.Methods) methodPoints.TryAdd(method.Key, (source, method.Value)); + foreach (KeyValuePair declaration in source.Declarations) declarations.TryAdd(declaration.Key, declaration.Value); + foreach (KeyValuePair> method in source.Methods) methodPoints.TryAdd(method.Key, (source, method.Value)); } // Accessors may be folded into properties/events. Their declaration navigation // maps to the exact accessor when present, otherwise its owning declaration. - foreach (var type in module.GetTypes()) + foreach (Mono.Cecil.TypeDefinition type in module.GetTypes()) { - foreach (var property in type.Properties) + foreach (Mono.Cecil.PropertyDefinition property in type.Properties) Alias(property, property.GetMethod, property.SetMethod); - foreach (var @event in type.Events) + foreach (Mono.Cecil.EventDefinition @event in type.Events) Alias(@event, @event.AddMethod, @event.RemoveMethod, @event.InvokeMethod); } void Alias(IMemberDefinition owner, params MethodDefinition[] accessors) { - if (!declarations.TryGetValue(owner.MetadataToken.ToInt32(), out var location)) return; - foreach (var method in accessors.Where(m => m != null)) + if (!declarations.TryGetValue(owner.MetadataToken.ToInt32(), out Declaration location)) return; + foreach (MethodDefinition method in accessors.Where(m => m != null)) declarations.TryAdd(method.MetadataToken.ToInt32(), location with { Token = method.MetadataToken.ToInt32(), Name = method.FullName, Kind = "Method" }); } - foreach (var method in module.GetTypes().SelectMany(t => t.Methods).Where(m => m.HasBody)) + foreach (MethodDefinition method in module.GetTypes().SelectMany(t => t.Methods).Where(m => m.HasBody)) { int token = method.MetadataToken.ToInt32(); - if (!methodPoints.ContainsKey(token) && declarations.TryGetValue(token, out var declaration)) + if (!methodPoints.ContainsKey(token) && declarations.TryGetValue(token, out Declaration declaration)) { - var source = sourceByPath[declaration.File]; + Source source = sourceByPath[declaration.File]; methodPoints[token] = (source, new List { new(0, declaration.Line, declaration.Column, declaration.EndLine, declaration.EndColumn) }); } } int bodies = module.GetTypes().Sum(t => t.Methods.Count(m => m.HasBody)); if (methodPoints.Count < bodies) throw new InvalidDataException($"Navigation coverage incomplete: {methodPoints.Count} symbols for {bodies} method bodies"); string pdbPath = Path.ChangeExtension(shellPath, ".pdb"); - var pdb = BuildPdb(pe, module, ordered, declarations, methodPoints, dependencyDirectory); - File.WriteAllBytes(pdbPath, pdb.Bytes); + (byte[] Bytes, BlobContentId Id) = BuildPdb(pe, module, ordered, declarations, methodPoints, dependencyDirectory); + File.WriteAllBytes(pdbPath, Bytes); // Attach exactly the PDB we generated to this PE. No implementation is executed. - module.Write(shellPath, new WriterParameters { WriteSymbols = true, SymbolWriterProvider = new NavigationSymbolWriterProvider(pdb.Id, pdbPath, pdb.Bytes) }); + module.Write(shellPath, new WriterParameters { WriteSymbols = true, SymbolWriterProvider = new NavigationSymbolWriterProvider(Id, pdbPath, Bytes) }); // Store the source root once instead of repeating a long machine-specific // cache path for hundreds of thousands of declarations. var manifest = new Manifest(2, Program.Hash(shellPath), Program.Hash(pdbPath), typeof(CSharpDecompiler).Assembly.GetName().Version.ToString(), sourceRoot, - ordered.Length, declarations.Count, bodies, methodPoints.Count, new List(), + ordered.Length, declarations.Count, bodies, methodPoints.Count, [], declarations.Values.OrderBy(d => d.Token).Select(d => d with { File = Path.GetRelativePath(sourceRoot, d.File) }).ToList()); File.WriteAllText(Path.Combine(directory, "navigation.json"), JsonSerializer.Serialize(manifest)); WriteXmlDocumentation(shellPath, pe, declarations); @@ -148,48 +149,48 @@ internal static class SourceNavigation { sdkDirectory = Program.ResolveSdkDirectory(sdkDirectory); string library = Path.Combine(sdkDirectory, "lib", Program.ShellName + ".dll"), pdbPath = Path.ChangeExtension(library, ".pdb"); - var manifest = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(sdkDirectory, "navigation.json"))); + Manifest manifest = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(sdkDirectory, "navigation.json"))); if (Program.Hash(library) != manifest.AssemblySha256 || Program.Hash(pdbPath) != manifest.PdbSha256) throw new InvalidDataException("Source navigation manifest, DLL and PDB do not match"); - using var peStream = File.OpenRead(library); using var pe = new PEReader(peStream); - using var pdbStream = File.OpenRead(pdbPath); using var provider = MetadataReaderProvider.FromPortablePdbStream(pdbStream); - var pdb = provider.GetMetadataReader(); var dll = pe.GetMetadataReader(); + using FileStream peStream = File.OpenRead(library); using var pe = new PEReader(peStream); + using FileStream pdbStream = File.OpenRead(pdbPath); using var provider = MetadataReaderProvider.FromPortablePdbStream(pdbStream); + MetadataReader pdb = provider.GetMetadataReader(); MetadataReader dll = pe.GetMetadataReader(); var id = new BlobContentId(pdb.DebugMetadataHeader.Id); - var entry = pe.ReadDebugDirectory().Single(d => d.Type == DebugDirectoryEntryType.CodeView); - var codeview = pe.ReadCodeViewDebugDirectoryData(entry); + DebugDirectoryEntry entry = pe.ReadDebugDirectory().Single(d => d.Type == DebugDirectoryEntryType.CodeView); + CodeViewDebugDirectoryData codeview = pe.ReadCodeViewDebugDirectoryData(entry); if (codeview.Guid != id.Guid || entry.Stamp != id.Stamp || codeview.Age != 1) throw new InvalidDataException("PDB identity does not match navigation assembly"); - var checksum = pe.ReadDebugDirectory().Single(d => d.Type == DebugDirectoryEntryType.PdbChecksum); + DebugDirectoryEntry checksum = pe.ReadDebugDirectory().Single(d => d.Type == DebugDirectoryEntryType.PdbChecksum); if (!pe.ReadPdbChecksumDebugDirectoryData(checksum).Checksum.SequenceEqual(SHA256.HashData(File.ReadAllBytes(pdbPath)))) throw new InvalidDataException("PE/PDB checksum mismatch"); int documents = 0, methods = 0; - foreach (var documentHandle in pdb.Documents) + foreach (DocumentHandle documentHandle in pdb.Documents) { - var document = pdb.GetDocument(documentHandle); + System.Reflection.Metadata.Document document = pdb.GetDocument(documentHandle); if (pdb.GetGuid(document.Language) != CSharpLanguage) throw new InvalidDataException("Navigation document language is not C#"); - var embedded = pdb.GetCustomDebugInformation(documentHandle).Select(h => pdb.GetCustomDebugInformation(h)).Single(c => pdb.GetGuid(c.Kind) == EmbeddedSource); + System.Reflection.Metadata.CustomDebugInformation embedded = pdb.GetCustomDebugInformation(documentHandle).Select(h => pdb.GetCustomDebugInformation(h)).Single(c => pdb.GetGuid(c.Kind) == EmbeddedSource); byte[] content = DecodeEmbedded(pdb.GetBlobBytes(embedded.Value)); if (!SHA256.HashData(content).SequenceEqual(pdb.GetBlobBytes(document.Hash))) throw new InvalidDataException("Embedded-source checksum mismatch"); string path = pdb.GetString(document.Name); if (!File.Exists(path) || !File.ReadAllBytes(path).SequenceEqual(content)) throw new InvalidDataException("Local source does not match embedded source: " + path); documents++; } - foreach (var method in dll.MethodDefinitions) + foreach (MethodDefinitionHandle method in dll.MethodDefinitions) { - var definition = dll.GetMethodDefinition(method); + System.Reflection.Metadata.MethodDefinition definition = dll.GetMethodDefinition(method); if (definition.RelativeVirtualAddress == 0) continue; - var debug = pdb.GetMethodDebugInformation(method); - var points = debug.GetSequencePoints().ToArray(); - if (debug.Document.IsNil || points.Length == 0) throw new InvalidDataException("Method lacks navigation symbols: 0x" + MetadataTokens.GetToken(method).ToString("X8")); + System.Reflection.Metadata.MethodDebugInformation debug = pdb.GetMethodDebugInformation(method); + System.Reflection.Metadata.SequencePoint[] points = [.. debug.GetSequencePoints()]; + if (debug.Document.IsNil || points.Length == 0) throw new InvalidDataException("Method lacks navigation symbols: 0x" + MetadataTokens.GetToken(method).ToString("X8", CultureInfo.InvariantCulture)); int size = pe.GetMethodBody(definition.RelativeVirtualAddress).GetILContent().Length; if (points.Any(p => !p.IsHidden && (p.Offset >= size || p.StartLine <= 0 || p.EndLine < p.StartLine))) throw new InvalidDataException("Invalid navigation sequence point"); methods++; } - foreach (var type in dll.TypeDefinitions) + foreach (TypeDefinitionHandle type in dll.TypeDefinitions) { if (dll.GetString(dll.GetTypeDefinition(type).Name) == "") continue; - var custom = pdb.GetCustomDebugInformation(type).Select(h => pdb.GetCustomDebugInformation(h)).SingleOrDefault(c => pdb.GetGuid(c.Kind) == TypeDocuments); - if (custom.Value.IsNil) throw new InvalidDataException("Type lacks source documents: 0x" + MetadataTokens.GetToken(type).ToString("X8")); - var blob = pdb.GetBlobReader(custom.Value); + System.Reflection.Metadata.CustomDebugInformation custom = pdb.GetCustomDebugInformation(type).Select(h => pdb.GetCustomDebugInformation(h)).SingleOrDefault(c => pdb.GetGuid(c.Kind) == TypeDocuments); + if (custom.Value.IsNil) throw new InvalidDataException("Type lacks source documents: 0x" + MetadataTokens.GetToken(type).ToString("X8", CultureInfo.InvariantCulture)); + BlobReader blob = pdb.GetBlobReader(custom.Value); while (blob.RemainingBytes > 0) _ = pdb.GetDocument(MetadataTokens.DocumentHandle(blob.ReadCompressedInteger())); } if (documents != manifest.Documents || methods != manifest.MethodsWithBodies || manifest.Symbols.Select(s => s.Token).Distinct().Count() != manifest.Declarations) @@ -199,14 +200,14 @@ internal static class SourceNavigation internal static void RestoreSources(string sdkDirectory) { - using var stream = File.OpenRead(Path.Combine(sdkDirectory, "lib", Program.ShellName + ".pdb")); + using FileStream stream = File.OpenRead(Path.Combine(sdkDirectory, "lib", Program.ShellName + ".pdb")); using var provider = MetadataReaderProvider.FromPortablePdbStream(stream); - var reader = provider.GetMetadataReader(); - foreach (var handle in reader.Documents) + MetadataReader reader = provider.GetMetadataReader(); + foreach (DocumentHandle handle in reader.Documents) { string path = reader.GetString(reader.GetDocument(handle).Name); if (File.Exists(path)) continue; - var info = reader.GetCustomDebugInformation(handle).Select(h => reader.GetCustomDebugInformation(h)).Single(c => reader.GetGuid(c.Kind) == EmbeddedSource); + System.Reflection.Metadata.CustomDebugInformation info = reader.GetCustomDebugInformation(handle).Select(h => reader.GetCustomDebugInformation(h)).Single(c => reader.GetGuid(c.Kind) == EmbeddedSource); Directory.CreateDirectory(Path.GetDirectoryName(path)); File.WriteAllBytes(path, DecodeEmbedded(reader.GetBlobBytes(info.Value))); } @@ -225,38 +226,38 @@ internal static class SourceNavigation private static void ApplyImplicitDeclarations(Dictionary declarations, Mono.Cecil.TypeDefinition type) { - foreach (var property in type.Properties) - if (declarations.TryGetValue(property.MetadataToken.ToInt32(), out var location)) - foreach (var accessor in new[] { property.GetMethod, property.SetMethod }.Where(m => m != null)) + foreach (Mono.Cecil.PropertyDefinition property in type.Properties) + if (declarations.TryGetValue(property.MetadataToken.ToInt32(), out Declaration location)) + foreach (MethodDefinition accessor in new[] { property.GetMethod, property.SetMethod }.Where(m => m != null)) declarations.TryAdd(accessor.MetadataToken.ToInt32(), location with { Token = accessor.MetadataToken.ToInt32(), Name = accessor.FullName, Kind = "Method" }); - foreach (var @event in type.Events) - if (declarations.TryGetValue(@event.MetadataToken.ToInt32(), out var location)) - foreach (var accessor in new[] { @event.AddMethod, @event.RemoveMethod, @event.InvokeMethod }.Where(m => m != null)) + foreach (Mono.Cecil.EventDefinition @event in type.Events) + if (declarations.TryGetValue(@event.MetadataToken.ToInt32(), out Declaration location)) + foreach (MethodDefinition accessor in new[] { @event.AddMethod, @event.RemoveMethod, @event.InvokeMethod }.Where(m => m != null)) declarations.TryAdd(accessor.MetadataToken.ToInt32(), location with { Token = accessor.MetadataToken.ToInt32(), Name = accessor.FullName, Kind = "Method" }); - if (declarations.TryGetValue(type.MetadataToken.ToInt32(), out var declaration)) + if (declarations.TryGetValue(type.MetadataToken.ToInt32(), out Declaration declaration)) { - foreach (var constructor in type.Methods.Where(m => m.IsConstructor && !declarations.ContainsKey(m.MetadataToken.ToInt32()))) + foreach (MethodDefinition constructor in type.Methods.Where(m => m.IsConstructor && !declarations.ContainsKey(m.MetadataToken.ToInt32()))) declarations.TryAdd(constructor.MetadataToken.ToInt32(), declaration with { Token = constructor.MetadataToken.ToInt32(), Name = constructor.FullName, Kind = "Method" }); - foreach (var field in type.Fields.Where(f => f.IsRuntimeSpecialName)) + foreach (Mono.Cecil.FieldDefinition field in type.Fields.Where(f => f.IsRuntimeSpecialName)) declarations.TryAdd(field.MetadataToken.ToInt32(), declaration with { Token = field.MetadataToken.ToInt32(), Name = field.FullName, Kind = "Field" }); } - foreach (var nested in type.NestedTypes) ApplyImplicitDeclarations(declarations, nested); + foreach (Mono.Cecil.TypeDefinition nested in type.NestedTypes) ApplyImplicitDeclarations(declarations, nested); } private static IEnumerable Descendants(MetadataReader reader, TypeDefinitionHandle handle) { yield return handle; - foreach (var child in reader.GetTypeDefinition(handle).GetNestedTypes()) - foreach (var nested in Descendants(reader, child)) yield return nested; + foreach (TypeDefinitionHandle child in reader.GetTypeDefinition(handle).GetNestedTypes()) + foreach (TypeDefinitionHandle nested in Descendants(reader, child)) yield return nested; } private static string PathFor(MetadataReader reader, TypeDefinitionHandle handle) { - var type = reader.GetTypeDefinition(handle); + System.Reflection.Metadata.TypeDefinition type = reader.GetTypeDefinition(handle); static string Safe(string name) => string.Concat(name.Select(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.' ? c : '_')); string ns = reader.GetString(type.Namespace), name = reader.GetString(type.Name); // Token suffix prevents collisions from generic arity, case-insensitive filesystems, // and names whose invalid filename characters normalize to the same spelling. - return Path.Combine(Safe(ns), Safe(name) + "." + MetadataTokens.GetToken(handle).ToString("X8") + ".cs"); + return Path.Combine(Safe(ns), Safe(name) + "." + MetadataTokens.GetToken(handle).ToString("X8", CultureInfo.InvariantCulture) + ".cs"); } private sealed class Worker : IDisposable @@ -278,24 +279,32 @@ internal static class SourceNavigation } private static DecompilerSettings Settings(bool raw) => new() { - ThrowOnAssemblyResolveErrors = true, UseDebugSymbols = false, ShowXmlDocumentation = false, - UseNestedDirectoriesForNamespaces = true, AnonymousMethods = !raw, AnonymousTypes = !raw, - AsyncAwait = !raw, YieldReturn = !raw, AutomaticProperties = !raw, AutomaticEvents = !raw, - GetterOnlyAutomaticProperties = !raw, UseExpressionBodyForCalculatedGetterOnlyProperties = !raw + ThrowOnAssemblyResolveErrors = true, + UseDebugSymbols = false, + ShowXmlDocumentation = false, + UseNestedDirectoriesForNamespaces = true, + AnonymousMethods = !raw, + AnonymousTypes = !raw, + AsyncAwait = !raw, + YieldReturn = !raw, + AutomaticProperties = !raw, + AutomaticEvents = !raw, + GetterOnlyAutomaticProperties = !raw, + UseExpressionBodyForCalculatedGetterOnlyProperties = !raw }; internal Source Decompile(TypeDefinitionHandle type, string path, bool raw) { - var decompiler = raw ? this.raw : primary; - return Render(decompiler, decompiler.DecompileTypes(new[] { type }), path, raw); + CSharpDecompiler decompiler = raw ? this.raw : primary; + return Render(decompiler, decompiler.DecompileTypes((TypeDefinitionHandle[])[type]), path, raw); } internal Source DecompileMember(EntityHandle member, string path) { - var source = Render(raw, raw.Decompile(member), path, true); + Source source = Render(raw, raw.Decompile(member), path, true); int token = MetadataTokens.GetToken(member); if (!source.Declarations.ContainsKey(token)) { - var text = Encoding.UTF8.GetString(source.Content); - source.Declarations[token] = new Declaration(token, "Metadata declaration 0x" + token.ToString("X8"), member.Kind.ToString(), path, 2, 1, text.Count(c => c == '\n') + 1, 1, true); + string text = Encoding.UTF8.GetString(source.Content); + source.Declarations[token] = new Declaration(token, "Metadata declaration 0x" + token.ToString("X8", CultureInfo.InvariantCulture), member.Kind.ToString(), path, 2, 1, text.Count(c => c == '\n') + 1, 1, true); } return source; } @@ -305,11 +314,11 @@ internal static class SourceNavigation // Keep token identity in the index/PDB while spelling those identifiers // legally in the generated view, so one state machine cannot break the // semantic parser and navigation for all ordinary members in its file. - foreach (var identifier in tree.DescendantsAndSelf.OfType()) + foreach (Identifier identifier in tree.DescendantsAndSelf.OfType()) { string name = identifier.Name; if (name.Any(c => !(char.IsLetterOrDigit(c) || c == '_'))) - identifier.Name = "__generated_" + string.Concat(name.Select(c => char.IsLetterOrDigit(c) || c == '_' ? c.ToString() : "u" + ((int)c).ToString("X4"))); + identifier.Name = "__generated_" + string.Concat(name.Select(c => char.IsLetterOrDigit(c) || c == '_' ? c.ToString() : "u" + ((int)c).ToString("X4", CultureInfo.InvariantCulture))); } tree.InsertChildAfter(null, new Comment(" Decompiled from the matching game DLL using the shared BD2 names table. For navigation; not compiled or executed."), Roles.Comment); using var text = new StringWriter(System.Globalization.CultureInfo.InvariantCulture); @@ -319,20 +328,20 @@ internal static class SourceNavigation Directory.CreateDirectory(Path.GetDirectoryName(path)); File.WriteAllBytes(path, content); var declarations = new Dictionary(); - foreach (var node in tree.DescendantsAndSelf) + foreach (AstNode node in tree.DescendantsAndSelf) { if (node is not EntityDeclaration && node is not VariableInitializer && node is not Accessor) continue; if (node.GetSymbol() is not IEntity entity || entity.MetadataToken.IsNil) continue; int token = MetadataTokens.GetToken(entity.MetadataToken); - var start = node is EntityDeclaration declaration && !declaration.NameToken.IsNull ? declaration.NameToken.StartLocation : node.StartLocation; - var end = node.EndLocation; + TextLocation start = node is EntityDeclaration declaration && !declaration.NameToken.IsNull ? declaration.NameToken.StartLocation : node.StartLocation; + TextLocation end = node.EndLocation; if (start.Line <= 0 || end.Line < start.Line) continue; declarations.TryAdd(token, new Declaration(token, entity.ReflectionName, entity.SymbolKind.ToString(), path, start.Line, start.Column, end.Line, end.Column, generated)); } var points = new Dictionary>(); - foreach (var function in decompiler.CreateSequencePoints(tree)) + foreach (KeyValuePair> function in decompiler.CreateSequencePoints(tree)) { - var method = function.Key.MoveNextMethod ?? function.Key.Method; + IMethod method = function.Key.MoveNextMethod ?? function.Key.Method; if (method == null || method.MetadataToken.IsNil) continue; int token = MetadataTokens.GetToken(method.MetadataToken); var sequence = function.Value.Where(p => !p.IsHidden).Select(p => new Point(p.Offset, p.StartLine, p.StartColumn, p.EndLine, p.EndColumn)).ToList(); @@ -349,11 +358,11 @@ internal static class SourceNavigation var metadata = new MetadataBuilder(); var documents = new Dictionary(StringComparer.Ordinal); var custom = new List<(EntityHandle Parent, Guid Kind, byte[] Bytes)>(); - var csharp = metadata.GetOrAddGuid(CSharpLanguage); - var sha256 = metadata.GetOrAddGuid(new Guid("8829D00F-11B8-4213-878B-770E8597AC16")); - foreach (var source in sources) + GuidHandle csharp = metadata.GetOrAddGuid(CSharpLanguage); + GuidHandle sha256 = metadata.GetOrAddGuid(new Guid("8829D00F-11B8-4213-878B-770E8597AC16")); + foreach (Source source in sources) { - var document = metadata.AddDocument(metadata.GetOrAddDocumentName(source.Path), sha256, metadata.GetOrAddBlob(SHA256.HashData(source.Content)), csharp); + DocumentHandle document = metadata.AddDocument(metadata.GetOrAddDocumentName(source.Path), sha256, metadata.GetOrAddBlob(SHA256.HashData(source.Content)), csharp); documents.Add(source.Path, document); using var stream = new MemoryStream(); using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true)) writer.Write(source.Content.Length); @@ -361,17 +370,17 @@ internal static class SourceNavigation custom.Add((document, EmbeddedSource, stream.ToArray())); } var methodDefs = module.GetTypes().SelectMany(t => t.Methods).ToDictionary(m => m.MetadataToken.ToInt32()); - foreach (var handle in pe.Metadata.MethodDefinitions) + foreach (MethodDefinitionHandle handle in pe.Metadata.MethodDefinitions) { int token = MetadataTokens.GetToken(handle); - if (!methods.TryGetValue(token, out var mapped)) { metadata.AddMethodDebugInformation(default, default); continue; } - var points = mapped.Points.Where(p => p.Offset >= 0 && p.Offset < methodDefs[token].Body.CodeSize && p.Line > 0 && p.EndLine >= p.Line) - .GroupBy(p => p.Offset).Select(g => g.First()).OrderBy(p => p.Offset).ToArray(); + if (!methods.TryGetValue(token, out (Source Source, List Points) mapped)) { metadata.AddMethodDebugInformation(default, default); continue; } + Point[] points = [.. mapped.Points.Where(p => p.Offset >= 0 && p.Offset < methodDefs[token].Body.CodeSize && p.Line > 0 && p.EndLine >= p.Line) + .GroupBy(p => p.Offset).Select(g => g.First()).OrderBy(p => p.Offset)]; var blob = new BlobBuilder(); blob.WriteCompressedInteger(0); // no local signature is needed for navigation int previousOffset = 0, previousLine = 0, previousColumn = 0; for (int i = 0; i < points.Length; i++) { - var p = points[i]; blob.WriteCompressedInteger(i == 0 ? p.Offset : p.Offset - previousOffset); + Point p = points[i]; blob.WriteCompressedInteger(i == 0 ? p.Offset : p.Offset - previousOffset); int lines = p.EndLine - p.Line; blob.WriteCompressedInteger(lines); int columns = p.EndColumn - p.Column; if (lines == 0) blob.WriteCompressedInteger(Math.Max(1, columns)); else blob.WriteCompressedSignedInteger(columns); @@ -381,21 +390,21 @@ internal static class SourceNavigation } metadata.AddMethodDebugInformation(documents[mapped.Source.Path], metadata.GetOrAddBlob(blob)); } - foreach (var type in module.GetTypes()) + foreach (Mono.Cecil.TypeDefinition type in module.GetTypes()) { - if (!declarations.TryGetValue(type.MetadataToken.ToInt32(), out var d)) continue; + if (!declarations.TryGetValue(type.MetadataToken.ToInt32(), out Declaration d)) continue; var docs = new BlobBuilder(); docs.WriteCompressedInteger(MetadataTokens.GetRowNumber(documents[d.File])); custom.Add((MetadataTokens.EntityHandle(type.MetadataToken.ToInt32()), TypeDocuments, docs.ToArray())); } - var options = Encoding.UTF8.GetBytes("language\0C#\0language-version\0" + "12.0\0compiler-version\0BD2.GameSdk\0output-kind\0DynamicallyLinkedLibrary\0optimization\0debug\0"); + byte[] options = Encoding.UTF8.GetBytes("language\0C#\0language-version\0" + "12.0\0compiler-version\0BD2.GameSdk\0output-kind\0DynamicallyLinkedLibrary\0optimization\0debug\0"); custom.Add((MetadataTokens.EntityHandle(1), CompilationOptions, options)); var references = new BlobBuilder(); - foreach (var reference in module.AssemblyReferences) + foreach (Mono.Cecil.AssemblyNameReference reference in module.AssemblyReferences) { string path = Path.Combine(dependencies, reference.Name + ".dll"); if (!File.Exists(path)) continue; - using var stream = File.OpenRead(path); using var dependency = new PEReader(stream); - var reader = dependency.GetMetadataReader(); + using FileStream stream = File.OpenRead(path); using var dependency = new PEReader(stream); + MetadataReader reader = dependency.GetMetadataReader(); references.WriteBytes(Encoding.UTF8.GetBytes(Path.GetFileName(path))); references.WriteByte(0); references.WriteByte(0); // global alias references.WriteByte(1); // combined embedInteropTypes=false, image kind=assembly @@ -404,16 +413,16 @@ internal static class SourceNavigation references.WriteBytes(reader.GetGuid(reader.GetModuleDefinition().Mvid).ToByteArray()); } custom.Add((MetadataTokens.EntityHandle(1), CompilationReferences, references.ToArray())); - foreach (var info in custom.OrderBy(c => CodedIndex.HasCustomDebugInformation(c.Parent))) - metadata.AddCustomDebugInformation(info.Parent, metadata.GetOrAddGuid(info.Kind), metadata.GetOrAddBlob(info.Bytes)); + foreach ((EntityHandle Parent, Guid Kind, byte[] Bytes) in custom.OrderBy(c => CodedIndex.HasCustomDebugInformation(c.Parent))) + metadata.AddCustomDebugInformation(Parent, metadata.GetOrAddGuid(Kind), metadata.GetOrAddBlob(Bytes)); var counts = Enumerable.Range(0, 64).Select(i => pe.Metadata.GetTableRowCount((TableIndex)i)).ToImmutableArray(); var builder = new PortablePdbBuilder(metadata, counts, default, blobs => { using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - foreach (var blob in blobs) hash.AppendData(blob.GetBytes()); + foreach (Blob blob in blobs) hash.AppendData(blob.GetBytes()); return BlobContentId.FromHash(hash.GetHashAndReset()); }); - var output = new BlobBuilder(); var id = builder.Serialize(output); + var output = new BlobBuilder(); BlobContentId id = builder.Serialize(output); return (output.ToArray(), id); } @@ -428,14 +437,14 @@ internal static class SourceNavigation // navigation. Repeating the same boilerplate for every member inflated // each XML file to hundreds of MB without adding API documentation. foreach (IEntity entity in decompiler.TypeSystem.MainModule.TypeDefinitions) - { - if (entity.MetadataToken.IsNil || !declarations.TryGetValue(MetadataTokens.GetToken(entity.MetadataToken), out var location)) continue; - string id = ICSharpCode.Decompiler.Documentation.IdStringProvider.GetIdString(entity); - writer.WriteStartElement("member"); writer.WriteAttributeString("name", id); - writer.WriteElementString("summary", "Readable game type. Decompiled source: " + Path.GetRelativePath(Path.Combine(Path.GetDirectoryName(assembly), "sources"), location.File) + ":" + location.Line); - writer.WriteElementString("remarks", "Navigation-only reference; the runtime implementation is in the matching Assembly-CSharp."); - writer.WriteEndElement(); - } + { + if (entity.MetadataToken.IsNil || !declarations.TryGetValue(MetadataTokens.GetToken(entity.MetadataToken), out Declaration location)) continue; + string id = ICSharpCode.Decompiler.Documentation.IdStringProvider.GetIdString(entity); + writer.WriteStartElement("member"); writer.WriteAttributeString("name", id); + writer.WriteElementString("summary", "Readable game type. Decompiled source: " + Path.GetRelativePath(Path.Combine(Path.GetDirectoryName(assembly), "sources"), location.File) + ":" + location.Line); + writer.WriteElementString("remarks", "Navigation-only reference; the runtime implementation is in the matching Assembly-CSharp."); + writer.WriteEndElement(); + } writer.WriteEndElement(); writer.WriteEndElement(); } @@ -455,10 +464,10 @@ internal static class SourceNavigation using var stream = new MemoryStream(); using var writer = new BinaryWriter(stream); writer.Write(0x53445352); writer.Write(id.Guid.ToByteArray()); writer.Write(1); writer.Write(Encoding.UTF8.GetBytes(path)); writer.Write((byte)0); byte[] codeview = stream.ToArray(); - return new ImageDebugHeader(new[] { + return new ImageDebugHeader([ new ImageDebugHeaderEntry(new ImageDebugDirectory { Type = ImageDebugType.CodeView, MajorVersion = 0x100, MinorVersion = 0x504d, TimeDateStamp = (int)id.Stamp, SizeOfData = codeview.Length }, codeview), - new ImageDebugHeaderEntry(new ImageDebugDirectory { Type = ImageDebugType.PdbChecksum, SizeOfData = 39 }, Encoding.UTF8.GetBytes("SHA256\0").Concat(SHA256.HashData(pdb)).ToArray()) - }); + new ImageDebugHeaderEntry(new ImageDebugDirectory { Type = ImageDebugType.PdbChecksum, SizeOfData = 39 }, [.. Encoding.UTF8.GetBytes("SHA256\0"), .. SHA256.HashData(pdb)]) + ]); } } } diff --git a/plugins/GameSdk/UpdateNames.ps1 b/plugins/GameSdk/UpdateNames.ps1 deleted file mode 100644 index 48a0e3f..0000000 --- a/plugins/GameSdk/UpdateNames.ps1 +++ /dev/null @@ -1,25 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)] [string]$GameDir, - [Parameter(Mandatory)] [string]$GameMapping, - [string]$VersionConfig -) -$ErrorActionPreference = 'Stop' -if (-not $VersionConfig) { $VersionConfig = Join-Path $PSScriptRoot '..\..\versions.json' } -$repository = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')) -$staging = Join-Path $repository ('.build\names-update\' + [guid]::NewGuid().ToString('N')) -New-Item -ItemType Directory -Force -Path $staging | Out-Null -function Invoke-Dotnet([string[]]$Arguments) { - & dotnet @Arguments - if ($LASTEXITCODE -ne 0) { throw "dotnet failed with exit code $LASTEXITCODE" } -} -Invoke-Dotnet @('build', (Join-Path $PSScriptRoot 'GameSdk.csproj'), '-c', 'Release', '--nologo') -$tool = Join-Path $PSScriptRoot 'bin\Release\net8.0\GameSdk.dll' -$assembly = Join-Path $GameDir 'BrownDust II_Data\Managed\Assembly-CSharp.dll' -$table = Join-Path $staging 'names.json' -Invoke-Dotnet @($tool, 'names', $assembly, $GameMapping, $VersionConfig, $table) -# Validate the full metadata transformation before replacing the checked-in table. -Invoke-Dotnet @($tool, 'shell', $table, $assembly, (Join-Path $staging 'Assembly-CSharp.Readable.dll')) -$destination = Join-Path $PSScriptRoot '..\GameNames\Mappings\names.json.gz' -Copy-Item -LiteralPath "$table.gz" -Destination $destination -Force -Write-Host "Updated SDK/runtime shared table: $destination. Rebuild SDK and plugins, then pack a new version." diff --git a/plugins/GameSdk/VerifyPackages.ps1 b/plugins/GameSdk/VerifyPackages.ps1 deleted file mode 100644 index 12948a9..0000000 --- a/plugins/GameSdk/VerifyPackages.ps1 +++ /dev/null @@ -1,82 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)] [string]$GameDir, - [string]$PackageVersion, - [string]$PackageDirectory -) -$ErrorActionPreference = 'Stop' -if (-not $PackageDirectory) { $PackageDirectory = Join-Path $PSScriptRoot '..\..\.build\nuget' } -if (-not $PackageVersion) { - [xml]$metadata = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\PackageMetadata.props') -Raw - $versions = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\versions.json') -Raw | ConvertFrom-Json - $PackageVersion = "$($metadata.Project.PropertyGroup.BD2PackageVersion)-game.$($versions.game_version)" -} -$PackageDirectory = [IO.Path]::GetFullPath($PackageDirectory) -# A fresh external directory and package cache prevent repository imports and stale -# same-version development packages from making an invalid package appear to work. -$verificationRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../.build/nuget-tests')) -$workspace = Join-Path $verificationRoot ([guid]::NewGuid().ToString('N')) -New-Item -ItemType Directory -Path $workspace | Out-Null -Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'samples\ExamplePlugin\ExamplePlugin.csproj'), (Join-Path $PSScriptRoot 'samples\ExamplePlugin\Plugin.cs') -Destination $workspace -$project = Join-Path $workspace 'ExamplePlugin.csproj' -$projectText = [IO.File]::ReadAllText($project).Replace('Version="0.2.1-game.2.35.10"', "Version=`"$PackageVersion`"") -[IO.File]::WriteAllText($project, $projectText) -$feed = [Security.SecurityElement]::Escape($PackageDirectory) -$cache = [Security.SecurityElement]::Escape((Join-Path $workspace 'packages')) -[IO.File]::WriteAllText((Join-Path $workspace 'NuGet.Config'), "") -function Invoke-Dotnet([string[]]$Arguments) { - & dotnet @Arguments - if ($LASTEXITCODE -ne 0) { throw "dotnet failed with exit code $LASTEXITCODE" } -} -$sharedCacheRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../.build/game-sdk')) -Invoke-Dotnet @('build', $project, '-c', 'Release', '--nologo', "-p:GameDir=$GameDir", "-p:BD2GameSdkCache=$sharedCacheRoot") -$output = Join-Path $workspace 'bin\Release\netstandard2.1' -$runtime = Join-Path $output 'BD2.GameNames.dll' -foreach ($required in @('BD2.GameNames.dll', 'ExamplePlugin.dll')) { - if (-not (Test-Path -LiteralPath (Join-Path $output $required))) { throw "Missing output: $required" } -} -foreach ($forbidden in @('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 (Test-Path -LiteralPath (Join-Path $output $forbidden)) { throw "Unexpected deployment artifact: $forbidden" } -} -$tool = Join-Path $workspace "packages\bd2.gamesdk\$PackageVersion\tools\net8.0\GameSdk.dll" -$sdk = Join-Path $workspace 'obj\Release\netstandard2.1\bd2-game-sdk' -$sharedSdk = [IO.File]::ReadAllText((Join-Path $sdk 'shared-sdk.txt')).Trim() -$table = Join-Path $sharedSdk 'names.json' -if ((Get-ChildItem -LiteralPath $sdk -Recurse -File | Measure-Object Length -Sum).Sum -gt 65536) { - throw 'SDK obj still contains large copied artifacts instead of shared references.' -} -[xml]$exampleProject = Get-Content -LiteralPath $project -Raw -if ([IO.Path]::GetFileName([IO.Path]::GetDirectoryName($sharedSdk)) -ne $exampleProject.Project.PropertyGroup.BD2GameVersion) { - throw 'Shared SDK directory is not grouped by the plugin-declared game version.' -} -$sharedReadyTimestamp = (Get-Item -LiteralPath (Join-Path $sharedSdk 'ready.txt')).LastWriteTimeUtc -Invoke-Dotnet @($tool, 'verify-runtime', $table, $runtime) -Invoke-Dotnet @($tool, 'verify-navigation', $sdk) -Invoke-Dotnet @($tool, 'verify', $table, (Join-Path $output 'ExamplePlugin.dll'), (Join-Path $GameDir 'BrownDust II_Data\Managed\Assembly-CSharp.dll')) -# Repeated builds must always transform the readable obj DLL, never reprocess bin. -$navigationProps = Join-Path $sdk 'GameSourceNavigation.props' -$navigationTimestamp = (Get-Item -LiteralPath $navigationProps).LastWriteTimeUtc -Invoke-Dotnet @('build', $project, '-c', 'Release', '--no-restore', '--nologo', "-p:GameDir=$GameDir", "-p:BD2GameSdkCache=$sharedCacheRoot") -if ((Get-Item -LiteralPath $navigationProps).LastWriteTimeUtc -ne $navigationTimestamp) { - throw 'Unchanged navigation props were rewritten; this triggers repeated IDE reloads.' -} -if ((Get-Item -LiteralPath (Join-Path $sharedSdk 'ready.txt')).LastWriteTimeUtc -ne $sharedReadyTimestamp) { - throw 'Repeated build unexpectedly regenerated the shared SDK.' -} -Invoke-Dotnet @($tool, 'verify', $table, (Join-Path $output 'ExamplePlugin.dll'), (Join-Path $GameDir 'BrownDust II_Data\Managed\Assembly-CSharp.dll')) -foreach ($invalidVersion in @('', '0.0.0')) { - $rejectedBuild = & dotnet build $project -c Release --no-restore --nologo "-p:GameDir=$GameDir" "-p:BD2GameSdkCache=$sharedCacheRoot" "-p:BD2GameVersion=$invalidVersion" 2>&1 | Out-String - if ($LASTEXITCODE -eq 0 -or ($invalidVersion -eq '' -and -not $rejectedBuild.Contains('BD2GameVersion')) -or - ($invalidVersion -ne '' -and -not $rejectedBuild.Contains('Plugin requires game 0.0.0'))) { - throw "Missing/mismatched game version was not rejected: $invalidVersion" - } -} -Write-Host "Verified external PackageReference consumer, complete source navigation, shared runtime and incremental rebuild: $workspace" -# Successful probes are disposable. Retain a failed probe for diagnostics, but -# do not accumulate package caches after successful verification. -$resolvedProbe = [IO.Path]::GetFullPath($workspace) -if ([IO.Path]::GetDirectoryName($resolvedProbe) -ne $verificationRoot -or - [IO.Path]::GetFileName($resolvedProbe) -notmatch '^[a-f0-9]{32}$') { - throw 'Invalid verification cleanup path.' -} -Remove-Item -LiteralPath $resolvedProbe -Recurse -Force diff --git a/plugins/GameSdk/samples/ExamplePlugin/ExamplePlugin.csproj b/plugins/GameSdk/samples/ExamplePlugin/ExamplePlugin.csproj index 19f230e..7710a8f 100644 --- a/plugins/GameSdk/samples/ExamplePlugin/ExamplePlugin.csproj +++ b/plugins/GameSdk/samples/ExamplePlugin/ExamplePlugin.csproj @@ -2,11 +2,24 @@ netstandard2.1 latest + + false + false ExamplePlugin 2.35.10 + $(GameDir)/BrownDust II_Data/Managed - + + + + $(BD2ManagedDir)/Unity.TextMeshPro.dll + false + + + $(BD2ManagedDir)/UnityEngine.UI.dll + false + diff --git a/plugins/GameSdk/samples/ExamplePlugin/NuGet.Config b/plugins/GameSdk/samples/ExamplePlugin/NuGet.Config index 08b3d47..7753a62 100644 --- a/plugins/GameSdk/samples/ExamplePlugin/NuGet.Config +++ b/plugins/GameSdk/samples/ExamplePlugin/NuGet.Config @@ -2,7 +2,7 @@ - + diff --git a/plugins/GameSdk/samples/ExamplePlugin/Plugin.cs b/plugins/GameSdk/samples/ExamplePlugin/Plugin.cs index 0f4db03..d4b2761 100644 --- a/plugins/GameSdk/samples/ExamplePlugin/Plugin.cs +++ b/plugins/GameSdk/samples/ExamplePlugin/Plugin.cs @@ -3,36 +3,31 @@ using System.Reflection; using BD2.GameNames; using BepInEx; using HarmonyLib; +using TMPro; namespace ExamplePlugin; -[BepInPlugin("example.readable-names", "Readable Names Example", "1.0.0")] +[BepInPlugin("example.plugin", "example plugin", "1.0.0")] public sealed class Plugin : BaseUnityPlugin { - private Harmony harmony; + private const string Greeting = "\nHello! Brown Dust Ⅱ"; + private static FieldInfo versionTextField; private void Awake() { - try - { - // Fail before installing any game patches when binary/table/plugin versions differ. - Game.Validate(typeof(Plugin).Assembly, message => Logger.LogInfo(message)); - MethodInfo target = Game.Method(ui => ui.SendMaintenanceInfo(false)); - harmony = new Harmony("example.readable-names"); - harmony.Patch(target, prefix: new HarmonyMethod(typeof(Plugin), nameof(BeforeMaintenance))); - - // Private members require the runtime string channel. The prefix expression above - // and the Unity callback name below belong to separate channels. - var awake = typeof(IntroUI).GetGameMethod("Awake", - BindingFlags.Instance | BindingFlags.NonPublic, null, Type.EmptyTypes, null); - Logger.LogInfo("Resolved IntroUI.Awake: " + (awake != null)); - } - catch (Exception exception) - { - Logger.LogError("Readable Names Example initialization failed: " + exception); - } + Game.Validate(typeof(Plugin).Assembly); + MethodInfo target = Game.Method(ui => ui.SetVersionText()); + versionTextField = typeof(IntroUI).GetGameField("_textVersion", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingFieldException("IntroUI._textVersion"); + var harmony = new Harmony("example.plugin"); + harmony.Patch(target, postfix: new HarmonyMethod(typeof(Plugin), nameof(AfterSetVersionText))); } - private static void BeforeMaintenance() { } - private void OnDestroy() => harmony?.UnpatchSelf(); + private static void AfterSetVersionText(IntroUI __instance) + { + if (versionTextField.GetValue(__instance) is not TMP_Text versionText) return; + string current = versionText.text ?? string.Empty; + if (current.EndsWith(Greeting, StringComparison.Ordinal)) return; + versionText.text = current + Greeting; + } } diff --git a/plugins/Generate-VersionSource.ps1 b/plugins/Generate-VersionSource.ps1 deleted file mode 100644 index aaaf6e4..0000000 --- a/plugins/Generate-VersionSource.ps1 +++ /dev/null @@ -1,48 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)] [string]$Config, - [Parameter(Mandatory)] [string]$Output, - [Parameter(Mandatory)] [ValidateSet('local_identity', 'capture_environment', 'login_ui', 'cash_shop')] [string]$Plugin -) - -$ErrorActionPreference = 'Stop' -$versions = Get-Content -LiteralPath $Config -Raw | ConvertFrom-Json -$pluginVersion = $versions.plugins.$Plugin -$semver = '^[0-9]+\.[0-9]+\.[0-9]+$' -$resource = '^[0-9]{14}$' -foreach ($entry in @( - @('game_version', $versions.game_version, $semver), - @('client_version', $versions.client_version, [regex]'^[0-9]+\.[0-9]+\.[0-9]+\+client\.[0-9]+\.[0-9]+\.[0-9]+$'), - @('server_version', $versions.server_version, [regex]'^[0-9]+\.[0-9]+\.[0-9]+\+server\.[0-9]+\.[0-9]+\.[0-9]+$'), - @('game_data_version', $versions.game_data_version, $resource), - @('bundle_version', $versions.bundle_version, $resource), - @("plugins.$Plugin", $pluginVersion, $semver) -)) { - if ([string]$entry[1] -notmatch $entry[2]) { - throw "Invalid $($entry[0]) in $Config" - } -} -if (-not $versions.client_version.StartsWith("$($versions.game_version)+client.", [StringComparison]::Ordinal) -or - -not $versions.server_version.StartsWith("$($versions.game_version)+server.", [StringComparison]::Ordinal)) { - throw 'client_version and server_version must use the configured game_version prefix.' -} - -$source = @" -// Generated from versions.json. Do not edit. -namespace Bd2Build -{ - internal static class Versions - { - internal const string Game = "$($versions.game_version)"; - internal const string ClientRelease = "$($versions.client_version)"; - internal const string GameData = "$($versions.game_data_version)"; - internal const string Bundle = "$($versions.bundle_version)"; - internal const string Plugin = "$pluginVersion"; - } -} -"@ -$directory = Split-Path -Parent $Output -if ($directory) { - New-Item -ItemType Directory -Force -Path $directory | Out-Null -} -[IO.File]::WriteAllText($Output, $source, [Text.UTF8Encoding]::new($false)) diff --git a/plugins/LocalIdentity/AgeGateState.cs b/plugins/LocalIdentity/AgeGateState.cs index 006f8d1..02fcf0e 100644 --- a/plugins/LocalIdentity/AgeGateState.cs +++ b/plugins/LocalIdentity/AgeGateState.cs @@ -14,7 +14,7 @@ internal static class AgeGateState { private const string FileName = "bd2.localidentity.age-gate.state"; private const string Content = "format=1\nconfirmed=true\n"; - private static readonly object Gate = new object(); + private static readonly object Gate = new(); private static bool? confirmed; internal static bool IsConfirmed() @@ -94,7 +94,7 @@ internal static class AgeGateState private static void WriteThrough(string path, string content) { byte[] bytes = new UTF8Encoding(false).GetBytes(content); - using FileStream stream = new FileStream( + using var stream = new FileStream( path, FileMode.CreateNew, FileAccess.Write, diff --git a/plugins/LocalIdentity/ClientDiagnostics.cs b/plugins/LocalIdentity/ClientDiagnostics.cs index 061f9b7..e0ccb7d 100644 --- a/plugins/LocalIdentity/ClientDiagnostics.cs +++ b/plugins/LocalIdentity/ClientDiagnostics.cs @@ -4,7 +4,6 @@ using static BD2.GameNames.Game; using System.Reflection; using BepInEx.Logging; using HarmonyLib; -using UnityEngine; using System.Diagnostics; using System.Threading; @@ -51,7 +50,7 @@ internal static class ClientDiagnostics FieldInfo timeout = __instance.GetType().GetGameField( "_maintenanceTimeoutCts", BindingFlags.Instance | BindingFlags.NonPublic); - CancellationTokenSource source = timeout?.GetValue(__instance) as CancellationTokenSource; + var source = timeout?.GetValue(__instance) as CancellationTokenSource; source?.Cancel(); Log?.LogInfo("Maintenance timeout guard cancelled after successful response"); } @@ -67,27 +66,17 @@ internal static class ClientDiagnostics internal static void InstallDatabaseDiagnostics(Harmony harmony) { Type rawDataManager = typeof(RawDataManager); - MethodInfo dbLoad = rawDataManager?.GetGameMethod( + MethodInfo dbLoad = (rawDataManager?.GetGameMethod( nameof(RawDataManager.DBLoad), - BindingFlags.Instance | BindingFlags.Public); - if (dbLoad == null) - { - throw new MissingMethodException("RawDataManager.DBLoad was not found"); - } - + BindingFlags.Instance | BindingFlags.Public)) ?? throw new MissingMethodException("RawDataManager.DBLoad was not found"); harmony.Patch( dbLoad, prefix: new HarmonyMethod(typeof(ClientDiagnostics), nameof(DBLoadPrefix))); Type clientLocalInfo = typeof(Proto.Local.ClientLocalInfo); - MethodInfo loadDB = clientLocalInfo?.GetGameMethod( + MethodInfo loadDB = (clientLocalInfo?.GetGameMethod( nameof(Proto.Local.ClientLocalInfo.LoadDB), - BindingFlags.Static | BindingFlags.Public); - if (loadDB == null) - { - throw new MissingMethodException("ClientLocalInfo.LoadDB was not found"); - } - + BindingFlags.Static | BindingFlags.Public)) ?? throw new MissingMethodException("ClientLocalInfo.LoadDB was not found"); harmony.Patch( loadDB, prefix: new HarmonyMethod(typeof(ClientDiagnostics), nameof(ClientLocalLoadPrefix)), @@ -101,7 +90,7 @@ internal static class ClientDiagnostics { string dbName = __0.dbName ?? ""; Action original = __1; - Stopwatch elapsed = Stopwatch.StartNew(); + var elapsed = Stopwatch.StartNew(); Log?.LogInfo("DBLoad start: " + dbName); __1 = delegate { diff --git a/plugins/LocalIdentity/ClientPresentation.cs b/plugins/LocalIdentity/ClientPresentation.cs index ebb5337..b35d286 100644 --- a/plugins/LocalIdentity/ClientPresentation.cs +++ b/plugins/LocalIdentity/ClientPresentation.cs @@ -26,7 +26,7 @@ internal static class ClientPresentation nameof(AppManager.SwitchToFullScreen), BindingFlags.Instance | BindingFlags.Public, null, - new[] { typeof(bool) }, + [typeof(bool)], null); if (initializeResolution == null || SwitchToFullScreenMethod == null) { @@ -45,7 +45,7 @@ internal static class ClientPresentation // Use the game's own FullScreenWindow path so the behaviour is // identical on Windows and macOS. This runs once during startup; // later user-initiated switches to windowed mode remain intact. - SwitchToFullScreenMethod?.Invoke(__instance, new object[] { false }); + SwitchToFullScreenMethod?.Invoke(__instance, [false]); } catch (Exception ex) { @@ -85,8 +85,8 @@ internal static class ClientPresentation throw new MissingMethodException("FPS_Check overlay methods were not found (client version mismatch)"); } - HarmonyMethod skip = new HarmonyMethod(typeof(ClientPresentation), nameof(SkipPerformanceOverlay)); - HarmonyMethod hide = new HarmonyMethod(typeof(ClientPresentation), nameof(HidePerformanceOverlayPostfix)); + var skip = new HarmonyMethod(typeof(ClientPresentation), nameof(SkipPerformanceOverlay)); + var hide = new HarmonyMethod(typeof(ClientPresentation), nameof(HidePerformanceOverlayPostfix)); harmony.Patch(start, prefix: skip); harmony.Patch(render, prefix: skip); harmony.Patch(onEnable, postfix: hide); diff --git a/plugins/LocalIdentity/ClientRouting.cs b/plugins/LocalIdentity/ClientRouting.cs index c307054..2ec9733 100644 --- a/plugins/LocalIdentity/ClientRouting.cs +++ b/plugins/LocalIdentity/ClientRouting.cs @@ -1,7 +1,6 @@ using System; using System.Reflection; using BD2.GameNames; -using static BD2.GameNames.Game; using HarmonyLib; using System.IO; using System.Net; @@ -15,6 +14,7 @@ namespace Bd2LocalIdentity; internal sealed class ClientRouting : IDisposable { + private static readonly char[] InvalidOriginCharacters = ['?', '#', '@', '%']; private static ClientRouting installedRouting; private static ManualLogSource routingLog; @@ -147,7 +147,7 @@ internal sealed class ClientRouting : IDisposable internal void ApplyServerOrigin() { - Uri gameEndpoint = new Uri(new Uri(config.server_origin + "/"), "game/"); + var gameEndpoint = new Uri(new Uri(config.server_origin + "/"), "game/"); BDNetwork.ServerURLInfo.SetUriDirectly(gameEndpoint); log.LogInfo("Client server origin applied: " + config.server_origin); } @@ -168,9 +168,9 @@ internal sealed class ClientRouting : IDisposable private static ResourcePolicy RequestResourcePolicy(ManualLogSource log, ClientConfig config) { - Uri endpoint = new Uri(new Uri(config.server_origin + "/"), "client/resources"); + var endpoint = new Uri(new Uri(config.server_origin + "/"), "client/resources"); byte[] body = Encoding.UTF8.GetBytes("{\"cdn_mode\":\"" + config.cdn_mode + "\"}"); - HttpWebRequest request = (HttpWebRequest)WebRequest.Create(endpoint); + var request = (HttpWebRequest)WebRequest.Create(endpoint); request.Method = "PUT"; request.ContentType = "application/json"; request.Accept = "application/json"; @@ -189,7 +189,7 @@ internal sealed class ClientRouting : IDisposable { stream.Write(body, 0, body.Length); } - using HttpWebResponse response = (HttpWebResponse)request.GetResponse(); + using var response = (HttpWebResponse)request.GetResponse(); log.LogInfo("Client resource policy response: origin=" + config.server_origin + " status=" + (int)response.StatusCode); if (response.StatusCode != HttpStatusCode.OK) @@ -215,7 +215,7 @@ internal sealed class ClientRouting : IDisposable { JObject value = ParseObject(json, "client configuration"); RequireOnly(value, "schema_version", "server_origin", "cdn_mode", "local_resource_directory", "proxy_url"); - ClientConfig config = new ClientConfig + var config = new ClientConfig { schema_version = RequiredInteger(value, "schema_version"), server_origin = RequiredString(value, "server_origin"), @@ -247,17 +247,17 @@ internal sealed class ClientRouting : IDisposable raw = raw.Trim(); if (raw.Length == 0) return string.Empty; const string error = "proxy_url must be an HTTP proxy address with an explicit port and no credentials or path"; - if (raw.IndexOfAny(new[] { '?', '#', '@', '%' }) >= 0) throw new InvalidDataException(error); + if (raw.IndexOfAny(InvalidOriginCharacters) >= 0) throw new InvalidDataException(error); foreach (char character in raw) if (char.IsWhiteSpace(character) || char.IsControl(character)) throw new InvalidDataException(error); if (!Uri.TryCreate(raw, UriKind.Absolute, out Uri proxy) || proxy.Scheme != "http" || string.IsNullOrEmpty(proxy.Host) || !string.IsNullOrEmpty(proxy.UserInfo) || !string.IsNullOrEmpty(proxy.Query) || !string.IsNullOrEmpty(proxy.Fragment) || proxy.AbsolutePath != "/") throw new InvalidDataException(error); - string authority = raw.Substring(raw.IndexOf("://", StringComparison.Ordinal) + 3).TrimEnd('/'); + string authority = raw[(raw.IndexOf("://", StringComparison.Ordinal) + 3)..].TrimEnd('/'); int separator = authority.LastIndexOf(':'); if (separator <= 0) throw new InvalidDataException(error); - string portText = authority.Substring(separator + 1); + string portText = authority[(separator + 1)..]; foreach (char character in portText) if (character < '0' || character > '9') throw new InvalidDataException(error); if (!int.TryParse(portText, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out int port) || port < 1 || port > 65535) throw new InvalidDataException(error); @@ -282,8 +282,8 @@ internal sealed class ClientRouting : IDisposable private static JObject ParseObject(string json, string description) { - using StringReader input = new StringReader(json); - using JsonTextReader reader = new JsonTextReader(input) + using var input = new StringReader(json); + using var reader = new JsonTextReader(input) { DateParseHandling = DateParseHandling.None }; @@ -353,13 +353,13 @@ internal sealed class ClientRouting : IDisposable { throw new FileNotFoundException("Client routing configuration is missing", path); } - using FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); return ReadBoundedStream(stream, maximumBytes); } private static string ReadBoundedStream(Stream stream, int maximumBytes) { - using MemoryStream output = new MemoryStream(); + using var output = new MemoryStream(); byte[] buffer = new byte[4096]; while (true) { diff --git a/plugins/LocalIdentity/LocalAccountPolicy.cs b/plugins/LocalIdentity/LocalAccountPolicy.cs index 688ef05..af22743 100644 --- a/plugins/LocalIdentity/LocalAccountPolicy.cs +++ b/plugins/LocalIdentity/LocalAccountPolicy.cs @@ -4,7 +4,6 @@ using static BD2.GameNames.Game; using System.Reflection; using BepInEx.Logging; using HarmonyLib; -using UnityEngine; namespace Bd2LocalIdentity; @@ -20,24 +19,16 @@ internal static class LocalAccountPolicy // change a later LoginUser parse after its successful local state has // been read from disk. Type commonPacket = typeof(BDNetwork.CommonPacket); - MethodInfo updateAgeGate = commonPacket?.GetGameMethod( + MethodInfo updateAgeGate = (commonPacket?.GetGameMethod( nameof(BDNetwork.CommonPacket.SendUpdateAgeGateRequest), BindingFlags.Static | BindingFlags.Public, null, - new[] { typeof(bool), typeof(int), typeof(int), typeof(int), typeof(Action) }, - null); - if (updateAgeGate == null) - { - throw new MissingMethodException("CommonPacket.SendUpdateAgeGateRequest(bool, int, int, int, Action) was not found"); - } + [typeof(bool), typeof(int), typeof(int), typeof(int), typeof(Action)], + null)) ?? throw new MissingMethodException("CommonPacket.SendUpdateAgeGateRequest(bool, int, int, int, Action) was not found"); Type loginUserResponse = typeof(Proto.Net.LoginUserResponse); - MethodInfo needsAgeVerificationSetter = loginUserResponse?.GetGameProperty( + MethodInfo needsAgeVerificationSetter = (loginUserResponse?.GetGameProperty( nameof(Proto.Net.LoginUserResponse.NeedsAgeVerification), - BindingFlags.Instance | BindingFlags.Public)?.GetSetMethod(); - if (needsAgeVerificationSetter == null) - { - throw new MissingMethodException("LoginUserResponse.NeedsAgeVerification setter was not found"); - } + BindingFlags.Instance | BindingFlags.Public)?.GetSetMethod()) ?? throw new MissingMethodException("LoginUserResponse.NeedsAgeVerification setter was not found"); harmony.Patch( updateAgeGate, prefix: new HarmonyMethod(typeof(LocalAccountPolicy), nameof(UpdateAgeGateRequestPrefix))); diff --git a/plugins/LocalIdentity/LocalResourceServer.cs b/plugins/LocalIdentity/LocalResourceServer.cs index 88fc39d..b21dc14 100644 --- a/plugins/LocalIdentity/LocalResourceServer.cs +++ b/plugins/LocalIdentity/LocalResourceServer.cs @@ -12,13 +12,16 @@ namespace Bd2LocalIdentity; internal sealed class LocalResourceServer : IDisposable { + private static readonly string[] HeaderSeparators = ["\r\n"]; + private static readonly char[] PathSeparators = ['/']; + private static readonly char[] QuerySeparators = ['?', '#']; private const int MaximumRequestHeaderBytes = 16 * 1024; private const int MaximumConcurrentRequests = 16; private readonly ManualLogSource log; private readonly TcpListener listener; private readonly Thread acceptThread; - private readonly Semaphore requestSlots = new Semaphore(MaximumConcurrentRequests, MaximumConcurrentRequests); + private readonly Semaphore requestSlots = new(MaximumConcurrentRequests, MaximumConcurrentRequests); private volatile bool disposed; private LocalResourceServer(ManualLogSource log, string rootDirectory) @@ -48,7 +51,7 @@ internal sealed class LocalResourceServer : IDisposable string gameDataVersion) { string root = ValidateRoot(configuredDirectory, bundleVersion, gameDataVersion); - LocalResourceServer server = new LocalResourceServer(log, root); + var server = new LocalResourceServer(log, root); log.LogInfo("Local resource HTTP server listening on " + server.Origin); return server; } @@ -182,7 +185,7 @@ internal sealed class LocalResourceServer : IDisposable return; } - string[] lines = header.Split(new[] { "\r\n" }, StringSplitOptions.None); + string[] lines = header.Split(HeaderSeparators, StringSplitOptions.None); string[] requestLine = lines[0].Split(' '); if (requestLine.Length != 3 || (requestLine[2] != "HTTP/1.1" && requestLine[2] != "HTTP/1.0")) { @@ -204,7 +207,7 @@ internal sealed class LocalResourceServer : IDisposable return; } - using FileStream file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); long start = 0; long end = file.Length - 1; bool partial = false; @@ -219,7 +222,7 @@ internal sealed class LocalResourceServer : IDisposable } long contentLength = file.Length == 0 ? 0 : end - start + 1; - StringBuilder response = new StringBuilder(); + var response = new StringBuilder(); response.Append(partial ? "HTTP/1.1 206 Partial Content\r\n" : "HTTP/1.1 200 OK\r\n"); response.Append("Content-Length: ").Append(contentLength).Append("\r\n"); response.Append("Content-Type: ").Append(GetContentType(path)).Append("\r\n"); @@ -253,8 +256,8 @@ internal sealed class LocalResourceServer : IDisposable private string ResolvePath(string requestTarget) { - int query = requestTarget.IndexOfAny(new[] { '?', '#' }); - string rawPath = query >= 0 ? requestTarget.Substring(0, query) : requestTarget; + int query = requestTarget.IndexOfAny(QuerySeparators); + string rawPath = query >= 0 ? requestTarget[..query] : requestTarget; string decoded; try { @@ -264,19 +267,19 @@ internal sealed class LocalResourceServer : IDisposable { return null; } - if (decoded.IndexOf('\0') >= 0) + if (decoded.Contains('\0')) { return null; } - string[] parts = decoded.Replace('\\', '/').Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + string[] parts = decoded.Replace('\\', '/').Split(PathSeparators, StringSplitOptions.RemoveEmptyEntries); if (parts.Length < 2 || (parts[0] != "ServerData" && parts[0] != "GameData")) { return null; } foreach (string part in parts) { - if (part == "." || part == ".." || part.IndexOf(':') >= 0) + if (part == "." || part == ".." || part.Contains(':')) { return null; } @@ -320,7 +323,7 @@ internal sealed class LocalResourceServer : IDisposable return false; } - private static string ReadRequestHeader(Stream stream) + private static string ReadRequestHeader(NetworkStream stream) { byte[] bytes = new byte[MaximumRequestHeaderBytes]; int count = 0; @@ -343,7 +346,7 @@ internal sealed class LocalResourceServer : IDisposable private static Dictionary ParseHeaders(string[] lines) { - Dictionary result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); for (int i = 1; i < lines.Length; i++) { int separator = lines[i].IndexOf(':'); @@ -351,7 +354,7 @@ internal sealed class LocalResourceServer : IDisposable { continue; } - result[lines[i].Substring(0, separator).Trim()] = lines[i].Substring(separator + 1).Trim(); + result[lines[i][..separator].Trim()] = lines[i][(separator + 1)..].Trim(); } return result; } @@ -360,11 +363,11 @@ internal sealed class LocalResourceServer : IDisposable { start = 0; end = length - 1; - if (length == 0 || !value.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase) || value.IndexOf(',') >= 0) + if (length == 0 || !value.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase) || value.Contains(',')) { return false; } - string[] bounds = value.Substring(6).Split('-'); + string[] bounds = value[6..].Split('-'); if (bounds.Length != 2) { return false; @@ -393,17 +396,12 @@ internal sealed class LocalResourceServer : IDisposable private static string GetContentType(string path) { - switch (Path.GetExtension(path).ToLowerInvariant()) + return Path.GetExtension(path).ToLowerInvariant() switch { - case ".json": - return "application/json; charset=utf-8"; - case ".hash": - case ".info": - case ".version": - return "text/plain; charset=utf-8"; - default: - return "application/octet-stream"; - } + ".json" => "application/json; charset=utf-8", + ".hash" or ".info" or ".version" => "text/plain; charset=utf-8", + _ => "application/octet-stream", + }; } private static void WriteError(Stream stream, int status, string reason, string additionalHeaders = "") diff --git a/plugins/LocalIdentity/Plugin.cs b/plugins/LocalIdentity/Plugin.cs index e3b2cc4..6b1290c 100644 --- a/plugins/LocalIdentity/Plugin.cs +++ b/plugins/LocalIdentity/Plugin.cs @@ -6,14 +6,13 @@ using System.Threading; using BepInEx; using BepInEx.Logging; using HarmonyLib; -using UnityEngine; namespace Bd2LocalIdentity; -[BepInPlugin(Guid, Name, Version)] +[BepInPlugin(PluginId, Name, Version)] public sealed class Plugin : BaseUnityPlugin { - public const string Guid = "bd2.localidentity"; + public const string PluginId = "bd2.localidentity"; public const string Name = "BD2 Local Identity"; public const string Version = Bd2Build.Versions.Plugin; private static ManualLogSource Log; @@ -47,7 +46,10 @@ public sealed class Plugin : BaseUnityPlugin MethodInfo prefix = typeof(Plugin).GetGameMethod( nameof(UseSdkPrefix), BindingFlags.Static | BindingFlags.NonPublic); - Harmony harmony = new Harmony(Guid); + // Harmony.Dispose calls UnpatchSelf; these hooks live until process exit. +#pragma warning disable CA2000 + var harmony = new Harmony(PluginId); +#pragma warning restore CA2000 LocalLoginState.Install(harmony, Routing.ServerOrigin, Logger); harmony.Patch(getter, prefix: new HarmonyMethod(prefix)); TryInstall("OS time zone device country", () => SystemTimeZoneRegion.Install(harmony, Logger)); @@ -70,6 +72,7 @@ public sealed class Plugin : BaseUnityPlugin } } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822", Justification = "Unity invokes this instance lifecycle callback.")] private void OnApplicationQuit() { DisposeRouting(); diff --git a/plugins/LocalIdentity/SystemTimeZoneCountries.cs b/plugins/LocalIdentity/SystemTimeZoneCountries.cs index 4ecb183..953472a 100644 --- a/plugins/LocalIdentity/SystemTimeZoneCountries.cs +++ b/plugins/LocalIdentity/SystemTimeZoneCountries.cs @@ -12,7 +12,7 @@ namespace Bd2LocalIdentity; // UTC, offset-only zones and unknown identifiers intentionally have no territory. internal static class SystemTimeZoneCountries { - private static readonly Dictionary Countries = new Dictionary(StringComparer.Ordinal) + private static readonly Dictionary Countries = new(StringComparer.Ordinal) { { "AUS Central Standard Time", "AU" }, { "AUS Eastern Standard Time", "AU" }, diff --git a/plugins/LocalIdentity/SystemTimeZoneRegion.cs b/plugins/LocalIdentity/SystemTimeZoneRegion.cs index bc06bb0..b89f40d 100644 --- a/plugins/LocalIdentity/SystemTimeZoneRegion.cs +++ b/plugins/LocalIdentity/SystemTimeZoneRegion.cs @@ -48,8 +48,7 @@ internal static class SystemTimeZoneRegion { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - DynamicTimeZoneInformation information; - if (GetDynamicTimeZoneInformation(out information) == uint.MaxValue) return string.Empty; + if (GetDynamicTimeZoneInformation(out DynamicTimeZoneInformation information) == uint.MaxValue) return string.Empty; return information.TimeZoneKeyName ?? string.Empty; } if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) diff --git a/plugins/LoginUI/ControlRequests.cs b/plugins/LoginUI/ControlRequests.cs index 3ed6342..1f5d109 100644 --- a/plugins/LoginUI/ControlRequests.cs +++ b/plugins/LoginUI/ControlRequests.cs @@ -23,8 +23,8 @@ internal static class ControlRequests // Native HTTP avoids UnityTls for owned auth/control endpoints. The coroutine // applies completed results on the Unity thread and rejects cancelled generations. internal static IEnumerator RequestControlEndpoint(Uri uri, string method, byte[] body, - string authorization, int timeoutSeconds, CancellationToken lifetime, CancellationToken generation, - Action completed) + string authorization, int timeoutSeconds, Action completed, + CancellationToken lifetime, CancellationToken generation) { try { ValidateOAuthTransport(uri); } catch @@ -32,62 +32,60 @@ internal static class ControlRequests if (body != null) Array.Clear(body, 0, body.Length); throw; } - using (CancellationTokenSource cancel = CancellationTokenSource.CreateLinkedTokenSource(lifetime, generation)) + using var cancel = CancellationTokenSource.CreateLinkedTokenSource(lifetime, generation); + byte[] payload = body == null ? null : (byte[])body.Clone(); + if (body != null) Array.Clear(body, 0, body.Length); + System.Threading.Tasks.Task task = null; + bool responseConsumed = false; + try { - byte[] payload = body == null ? null : (byte[])body.Clone(); - if (body != null) Array.Clear(body, 0, body.Length); - System.Threading.Tasks.Task task = null; - bool responseConsumed = false; + task = PlatformControlHttp.Send(uri, method, payload, authorization, timeoutSeconds, cancel.Token, generation); + // Native work may finish after its Unity owner is destroyed. Observe it + // and clear the upload clone only when transport no longer reads it. + task.ContinueWith(finished => + { + if (payload != null) Array.Clear(payload, 0, payload.Length); + if (finished.IsFaulted) { _ = finished.Exception; } + }, System.Threading.Tasks.TaskScheduler.Default); + while (!task.IsCompleted && !cancel.IsCancellationRequested) + { + yield return null; + } + if (cancel.IsCancellationRequested) yield break; + if (task.IsFaulted || task.IsCanceled) + { + completed(new ControlProbeResult { Error = task.IsCanceled ? "Cancelled" : "NativeTransportFailure" }); + yield break; + } + PlatformControlHttp.Response result = task.GetAwaiter().GetResult(); try { - task = PlatformControlHttp.Send(uri, method, payload, authorization, timeoutSeconds, cancel.Token, generation); - // Native work may finish after its Unity owner is destroyed. Observe it - // and clear the upload clone only when transport no longer reads it. - task.ContinueWith(finished => + completed(new ControlProbeResult { - if (payload != null) Array.Clear(payload, 0, payload.Length); - if (finished.IsFaulted) { _ = finished.Exception; } - }, System.Threading.Tasks.TaskScheduler.Default); - while (!task.IsCompleted && !cancel.IsCancellationRequested) - { - yield return null; - } - if (cancel.IsCancellationRequested) yield break; - if (task.IsFaulted || task.IsCanceled) - { - completed(new ControlProbeResult { Error = task.IsCanceled ? "Cancelled" : "NativeTransportFailure" }); - yield break; - } - PlatformControlHttp.Response result = task.GetAwaiter().GetResult(); - try - { - completed(new ControlProbeResult - { - Success = result.Success, - StatusCode = result.StatusCode, - Body = result.Body, - RefreshInvalid = result.RefreshInvalid, - Error = result.Error - }); - } - finally - { - ClearNativeResponse(result); - responseConsumed = true; - } + Success = result.Success, + StatusCode = result.StatusCode, + Body = result.Body, + RefreshInvalid = result.RefreshInvalid, + Error = result.Error + }); } finally { - cancel.Cancel(); - if (task == null && payload != null) Array.Clear(payload, 0, payload.Length); - if (task != null && !responseConsumed) + ClearNativeResponse(result); + responseConsumed = true; + } + } + finally + { + cancel.Cancel(); + if (task == null && payload != null) Array.Clear(payload, 0, payload.Length); + if (task != null && !responseConsumed) + { + task.ContinueWith(finished => { - task.ContinueWith(finished => - { - if (finished.Status == System.Threading.Tasks.TaskStatus.RanToCompletion) ClearNativeResponse(finished.Result); - if (finished.IsFaulted) { _ = finished.Exception; } - }, System.Threading.Tasks.TaskScheduler.Default); - } + if (finished.Status == System.Threading.Tasks.TaskStatus.RanToCompletion) ClearNativeResponse(finished.Result); + if (finished.IsFaulted) { _ = finished.Exception; } + }, System.Threading.Tasks.TaskScheduler.Default); } } } @@ -100,9 +98,11 @@ internal static class ControlRequests response.Body = null; } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1001", Justification = "Unity OnDestroy cancels and disposes Lifetime.")] public sealed class AuthenticationRequestLifetime : MonoBehaviour { - internal readonly CancellationTokenSource Lifetime = new CancellationTokenSource(); + internal readonly CancellationTokenSource Lifetime = new(); + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0051", Justification = "Unity invokes this instance lifecycle callback.")] private void OnDestroy() { Lifetime.Cancel(); @@ -113,7 +113,7 @@ internal static class ControlRequests internal static IEnumerator AuthRequest(object introUI, Uri uri, string method, byte[] body, string authorization, Action completed) { - MonoBehaviour intro = introUI as MonoBehaviour; + var intro = introUI as MonoBehaviour; if (intro == null) { if (body != null) Array.Clear(body, 0, body.Length); @@ -124,8 +124,8 @@ internal static class ControlRequests int expectedGeneration = Volatile.Read(ref RecoveryGeneration); CancellationToken lifetime = owner.Lifetime.Token; ControlProbeResult result = null; - yield return RequestControlEndpoint(uri, method, body, authorization, 15, lifetime, ControlProbeCancellation.Token, - response => result = response); + yield return RequestControlEndpoint(uri, method, body, authorization, 15, response => result = response, + lifetime, ControlProbeCancellation.Token); if (result != null && intro != null && !lifetime.IsCancellationRequested && expectedGeneration == Volatile.Read(ref RecoveryGeneration)) completed(result); } diff --git a/plugins/LoginUI/CredentialStores.cs b/plugins/LoginUI/CredentialStores.cs index 99ef5c9..635671c 100644 --- a/plugins/LoginUI/CredentialStores.cs +++ b/plugins/LoginUI/CredentialStores.cs @@ -2,7 +2,6 @@ using System; using System.ComponentModel; using System.IO; using System.Runtime.InteropServices; -using System.Security.Cryptography; using System.Text; using UnityEngine; using Bd2Login; @@ -241,7 +240,7 @@ internal sealed class WindowsDpapiRefreshCredentialStore : IRefreshCredentialSto { return default; } - DataBlob blob = new DataBlob { size = bytes.Length, data = Marshal.AllocHGlobal(bytes.Length) }; + var blob = new DataBlob { size = bytes.Length, data = Marshal.AllocHGlobal(bytes.Length) }; Marshal.Copy(bytes, 0, blob.data, bytes.Length); return blob; } @@ -341,8 +340,8 @@ internal sealed class MacOSKeychainRefreshCredentialStore : IRefreshCredentialSt public bool Contains(string origin) { - int status = Find(origin, out uint length, out IntPtr data, out IntPtr item); - ReleaseFound(length, data, item); + int status = Find(origin, out _, out IntPtr data, out IntPtr item); + ReleaseFound(data, item); if (status == Success) { return true; @@ -359,7 +358,7 @@ internal sealed class MacOSKeychainRefreshCredentialStore : IRefreshCredentialSt int status = Find(origin, out uint length, out IntPtr data, out IntPtr item); if (status != Success) { - ReleaseFound(length, data, item); + ReleaseFound(data, item); throw StatusException(status); } byte[] bytes = new byte[length]; @@ -371,7 +370,7 @@ internal sealed class MacOSKeychainRefreshCredentialStore : IRefreshCredentialSt finally { Array.Clear(bytes, 0, bytes.Length); - ReleaseFound(length, data, item); + ReleaseFound(data, item); } } @@ -380,12 +379,12 @@ internal sealed class MacOSKeychainRefreshCredentialStore : IRefreshCredentialSt byte[] value = Encoding.UTF8.GetBytes(JsonUtility.ToJson(credential)); try { - int status = Find(origin, out uint oldLength, out IntPtr oldData, out IntPtr item); + int status = Find(origin, out _, out IntPtr oldData, out IntPtr item); if (status == Success) { if (oldData != IntPtr.Zero) { - SecKeychainItemFreeContent(IntPtr.Zero, oldData); + ReleaseContent(oldData); } try { @@ -413,7 +412,7 @@ internal sealed class MacOSKeychainRefreshCredentialStore : IRefreshCredentialSt } else { - ReleaseFound(oldLength, oldData, item); + ReleaseFound(oldData, item); } if (status != Success) { @@ -428,10 +427,10 @@ internal sealed class MacOSKeychainRefreshCredentialStore : IRefreshCredentialSt public void Delete(string origin) { - int status = Find(origin, out uint length, out IntPtr data, out IntPtr item); + int status = Find(origin, out _, out IntPtr data, out IntPtr item); if (data != IntPtr.Zero) { - SecKeychainItemFreeContent(IntPtr.Zero, data); + ReleaseContent(data); } if (status == ItemNotFound) { @@ -477,11 +476,11 @@ internal sealed class MacOSKeychainRefreshCredentialStore : IRefreshCredentialSt return Encoding.UTF8.GetBytes("BD2 Login UI OAuth: " + origin); } - private static void ReleaseFound(uint length, IntPtr data, IntPtr item) + private static void ReleaseFound(IntPtr data, IntPtr item) { if (data != IntPtr.Zero) { - SecKeychainItemFreeContent(IntPtr.Zero, data); + ReleaseContent(data); } ReleaseItem(item); } @@ -494,6 +493,13 @@ internal sealed class MacOSKeychainRefreshCredentialStore : IRefreshCredentialSt } } + private static void ReleaseContent(IntPtr data) + { + int status = SecKeychainItemFreeContent(IntPtr.Zero, data); + if (status != Success) + LoginRuntime.Log?.LogWarning("macOS Keychain content cleanup failed with OSStatus " + status); + } + private static Exception StatusException(int status) { return status == ItemNotFound diff --git a/plugins/LoginUI/EventHubPresentation.cs b/plugins/LoginUI/EventHubPresentation.cs index 535418e..e31b77f 100644 --- a/plugins/LoginUI/EventHubPresentation.cs +++ b/plugins/LoginUI/EventHubPresentation.cs @@ -4,7 +4,6 @@ using BD2.GameNames; using Google.Protobuf; using HarmonyLib; using Proto.Net; -using UnityEngine; using gamfs; using static Bd2LoginUI.LoginRuntime; using static Bd2LoginUI.SessionRecovery; @@ -19,17 +18,18 @@ internal static class EventHubPresentation { MethodInfo receive = typeof(MiniEventHubPacket).GetGameMethod( "RecvMiniEventHubInfoResponse", BindingFlags.Static | BindingFlags.NonPublic, - null, new[] { typeof(IMessage), typeof(int), typeof(int), typeof(Action) }, null); + null, [typeof(IMessage), typeof(int), typeof(int), typeof(Action)], null); if (receive == null || receive.ReturnType != typeof(bool)) throw new MissingMethodException("MiniEventHubPacket.RecvMiniEventHubInfoResponse(IMessage) was not found"); harmony.Patch(receive, postfix: new HarmonyMethod(typeof(EventHubPresentation), nameof(ReceivePostfix))); Log?.LogInfo("Authoritative empty mini-event hub presentation reset installed"); } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0031", Justification = "UnityEngine.Object null checks also detect destroyed native objects.")] private static void ReceivePostfix(IMessage __0, int __2, bool __result) { if (ServerRoot == null || !EstablishedGameSession || !__result || __2 > 0 || - !(__0 is MiniEventHubInfoResponse response) || response.MiniEventHubInfo.Count != 0) return; + __0 is not MiniEventHubInfoResponse response || response.MiniEventHubInfo.Count != 0) return; try { // Detach the old manager subscriber before Reset removes its UID. diff --git a/plugins/LoginUI/EventRequestDiagnostics.cs b/plugins/LoginUI/EventRequestDiagnostics.cs index fc4e0a6..dc3b4f5 100644 --- a/plugins/LoginUI/EventRequestDiagnostics.cs +++ b/plugins/LoginUI/EventRequestDiagnostics.cs @@ -19,7 +19,7 @@ internal static class EventRequestDiagnostics { private const BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; private sealed class Sample { internal int Count; internal float Last, Logged; internal bool Burst; } - private static readonly Dictionary Samples = new Dictionary(); + private static readonly Dictionary Samples = []; internal static void Install(Harmony harmony) { @@ -65,7 +65,7 @@ internal static class EventRequestDiagnostics private static string Callers() { var text = new StringBuilder(); - foreach (StackFrame frame in new StackTrace(false).GetFrames() ?? Array.Empty()) + foreach (StackFrame frame in new StackTrace(false).GetFrames() ?? []) { MethodBase method = frame.GetMethod(); if (method?.DeclaringType == null || method.DeclaringType == typeof(EventRequestDiagnostics)) continue; diff --git a/plugins/LoginUI/LoginController.cs b/plugins/LoginUI/LoginController.cs index 4e0510e..01b8fa8 100644 --- a/plugins/LoginUI/LoginController.cs +++ b/plugins/LoginUI/LoginController.cs @@ -61,12 +61,12 @@ internal static class LoginController } try { - if (!(__instance is Component)) + if (__instance is not Component) { throw new InvalidOperationException("IntroUI is not a Unity component"); } Uri maintenance = CurrentMaintenanceUri(); - Uri currentRoot = new Uri(maintenance, "/"); + var currentRoot = new Uri(maintenance, "/"); if (ServerRoot == null || !SameOrigin(ServerRoot, currentRoot)) { AccessTokens.Clear(); @@ -79,12 +79,12 @@ internal static class LoginController ServerRoot = currentRoot; EnsureGameRelay(); } - if (Volatile.Read(ref SessionRecoveryInProgress) != 0 && Authentication != null && - Authentication.mode == "oauth" && AccessTokens.IsUsable(NormalizedServerOrigin())) - { - ContinueWithMaintenance(__instance, true); - return false; - } + if (Volatile.Read(ref SessionRecoveryInProgress) != 0 && Authentication != null && + Authentication.mode == "oauth" && AccessTokens.IsUsable(NormalizedServerOrigin())) + { + ContinueWithMaintenance(__instance, true); + return false; + } EnsureGameRelay(); if (Authentication != null) { @@ -109,7 +109,7 @@ internal static class LoginController private static IEnumerator LoadAuthenticationPolicy(object introUI, Uri expectedRoot, string expectedOrigin) { - Uri endpoint = new Uri(expectedRoot, "auth/config"); + var endpoint = new Uri(expectedRoot, "auth/config"); int generation = Volatile.Read(ref RecoveryGeneration); for (int attempt = 0; attempt < 3; attempt++) { @@ -167,7 +167,7 @@ internal static class LoginController { AccessTokens.Clear(); ContinueMaintenance = true; - SendMaintenance.Invoke(introUI, new object[] { true }); + SendMaintenance.Invoke(introUI, [true]); } finally { @@ -238,7 +238,7 @@ internal static class LoginController EstablishedGameSession = false; ConfigureLoginPanel(introUI); Type stateType = SetIntroState.GetParameters()[0].ParameterType; - SetIntroState.Invoke(introUI, new[] { Enum.ToObject(stateType, 1) }); + SetIntroState.Invoke(introUI, [Enum.ToObject(stateType, 1)]); Log?.LogInfo("event='Login required' reason=" + reason + " origin=" + NormalizedServerOrigin()); } @@ -322,7 +322,7 @@ internal static class LoginController if (!IsCurrentLogin(generation, origin)) { start.device_secret = null; yield break; } { ControlProbeResult request = null; - yield return AuthRequest(introUI, new Uri(endpoint), UnityWebRequest.kHttpVerbPOST, Array.Empty(), "Device " + start.device_secret, result => request = result); + yield return AuthRequest(introUI, new Uri(endpoint), UnityWebRequest.kHttpVerbPOST, [], "Device " + start.device_secret, result => request = result); if (request == null) yield break; if (!IsCurrentLogin(generation, origin)) { request.Body = null; start.device_secret = null; yield break; } if (request.StatusCode == 202) @@ -420,7 +420,7 @@ internal static class LoginController }; try { - OpenPCLoginPopup.Invoke(null, new object[] { confirmed }); + OpenPCLoginPopup.Invoke(null, [confirmed]); } catch { @@ -600,7 +600,7 @@ internal static class LoginController { ContinueMaintenance = true; Log?.LogInfo("Authenticated login is requesting maintenance information: automatic=" + automatic); - SendMaintenance.Invoke(introUI, new object[] { automatic }); + SendMaintenance.Invoke(introUI, [automatic]); } finally { @@ -611,7 +611,7 @@ internal static class LoginController private static void StartIntroCoroutine(object introUI, IEnumerator routine) { - if (!(introUI is MonoBehaviour owner) || owner == null) + if (introUI is not MonoBehaviour owner || owner == null) { throw new InvalidOperationException("IntroUI coroutine owner is unavailable"); } @@ -642,7 +642,7 @@ internal static class LoginController private static void StoreRefresh(TokenResult result) { string origin = NormalizedServerOrigin(); - RefreshCredential credential = new RefreshCredential + var credential = new RefreshCredential { version = 2, origin = origin, @@ -809,6 +809,7 @@ internal static class LoginController } [Serializable] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1805", Justification = "JsonUtility populates these fields by reflection; explicit defaults avoid CS0649.")] private sealed class DeviceStart { public string transaction_id = null; @@ -819,6 +820,7 @@ internal static class LoginController } [Serializable] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1805", Justification = "JsonUtility populates these fields by reflection; explicit defaults avoid CS0649.")] private sealed class TokenResult { public string provider = null; @@ -829,6 +831,7 @@ internal static class LoginController } [Serializable] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1805", Justification = "JsonUtility populates these fields by reflection; explicit defaults avoid CS0649.")] internal sealed class ServerAuthentication { public string mode = null; diff --git a/plugins/LoginUI/LoginPanel.cs b/plugins/LoginUI/LoginPanel.cs index 489880d..5a0904b 100644 --- a/plugins/LoginUI/LoginPanel.cs +++ b/plugins/LoginUI/LoginPanel.cs @@ -22,6 +22,7 @@ internal static class LoginPanel private static Sprite DiscordSymbol; private static Sprite DiscordWordmark; + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0270", Justification = "UnityEngine.Object null checks also detect destroyed native objects.")] internal static void ConfigureLoginPanel(object introUI) { if (Authentication == null || Authentication.mode != "oauth") @@ -30,7 +31,7 @@ internal static class LoginPanel } try { - Component component = introUI as Component; + var component = introUI as Component; Transform panel = component == null ? null : FindDescendant(component.transform, "SignInWithAccount"); if (panel == null) { @@ -86,6 +87,7 @@ internal static class LoginPanel } } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0270", Justification = "UnityEngine.Object null checks also detect destroyed native objects.")] private static void ConfigureProviderGrid(Transform panel, int providerCount) { GridLayoutGroup grid = panel.GetComponentInChildren(true); @@ -117,8 +119,7 @@ internal static class LoginPanel const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; UpdateBetterGridSettings(type.GetGameField("settingsFallback", flags)?.GetValue(grid), columns); object collection = type.GetGameField("customSettings", flags)?.GetValue(grid); - IEnumerable items = collection?.GetType().GetGameProperty("Items", flags)?.GetValue(collection, null) as IEnumerable; - if (items == null) + if (collection?.GetType().GetGameProperty("Items", flags)?.GetValue(collection, null) is not IEnumerable items) { return; } @@ -137,10 +138,7 @@ internal static class LoginPanel Type type = settings.GetType(); FieldInfo constraint = type.GetGameField("Constraint", BindingFlags.Instance | BindingFlags.Public); FieldInfo count = type.GetGameField("ConstraintCount", BindingFlags.Instance | BindingFlags.Public); - if (constraint != null) - { - constraint.SetValue(settings, Enum.ToObject(constraint.FieldType, (int)GridLayoutGroup.Constraint.FixedColumnCount)); - } + constraint?.SetValue(settings, Enum.ToObject(constraint.FieldType, (int)GridLayoutGroup.Constraint.FixedColumnCount)); count?.SetValue(settings, columns); } @@ -156,8 +154,7 @@ internal static class LoginPanel } UpdateBetterRectTransformData(type.GetGameField("transformFallback", flags)?.GetValue(component), width); object collection = type.GetGameField("transformConfigs", flags)?.GetValue(component); - IEnumerable items = collection?.GetType().GetGameProperty("Items", flags)?.GetValue(collection, null) as IEnumerable; - if (items == null) + if (collection?.GetType().GetGameProperty("Items", flags)?.GetValue(collection, null) is not IEnumerable items) { continue; } @@ -175,7 +172,7 @@ internal static class LoginPanel { return; } - Vector2 size = (Vector2)sizeField.GetValue(data); + var size = (Vector2)sizeField.GetValue(data); size.x = width; sizeField.SetValue(data, size); } @@ -215,11 +212,7 @@ internal static class LoginPanel private static Sprite LoadSprite(string resourceName, string name) { - using Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName); - if (stream == null) - { - throw new FileNotFoundException("Embedded login asset is missing", resourceName); - } + using Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName) ?? throw new FileNotFoundException("Embedded login asset is missing", resourceName); byte[] bytes = new byte[stream.Length]; int offset = 0; while (offset < bytes.Length) @@ -231,7 +224,7 @@ internal static class LoginPanel } offset += read; } - Texture2D texture = new Texture2D(2, 2, TextureFormat.RGBA32, false, false) + var texture = new Texture2D(2, 2, TextureFormat.RGBA32, false, false) { name = name, filterMode = FilterMode.Bilinear, @@ -242,7 +235,7 @@ internal static class LoginPanel UnityEngine.Object.Destroy(texture); throw new InvalidDataException("Could not decode embedded login asset " + resourceName); } - Sprite sprite = Sprite.Create( + var sprite = Sprite.Create( texture, new Rect(0f, 0f, texture.width, texture.height), new Vector2(0.5f, 0.5f), @@ -251,18 +244,21 @@ internal static class LoginPanel return sprite; } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0031", Justification = "UnityEngine.Object null checks also detect destroyed native objects.")] private static Button FindButton(Transform root, string name) { Transform match = FindDescendant(root, name); return match == null ? null : match.GetComponent