refactor(server): generate runtime configs and use .build/config for development

This commit is contained in:
2026-10-05 12:40:07 +08:00
parent d8771caef2
commit 3a9ebfdded
17 changed files with 105 additions and 122 deletions
+1 -1
View File
@@ -127,7 +127,7 @@ master key 必须长期稳定:
## 5. 编辑服务器配置
编辑与 `bd2server` 可执行文件同目录的 `authentication.json`。Discord 和 Google 都启用时:
首次运行时会在 `bd2server` 可执行文件旁生成 `local` 模式的默认配置。开发入口使用 `.build/config/authentication.json`;Docker 镜像默认使用 `/app/data/config/authentication.json`。启用 OAuth 前编辑生成的文件并配置环境变量,然后重启。Discord 和 Google 都启用时:
```json
{
+2 -2
View File
@@ -17,7 +17,7 @@ RUN apt-get update \
WORKDIR /app
COPY --from=build /out/bd2server /app/bd2server
COPY versions.json authentication.json resources.json game.json /app/
COPY versions.json /app/
COPY go/seed /app/go/seed
COPY schedules /app/schedules
RUN mkdir -p /app/data && chown 10001:10001 /app/data
@@ -25,4 +25,4 @@ RUN mkdir -p /app/data && chown 10001:10001 /app/data
USER 10001:10001
EXPOSE 8080
ENTRYPOINT ["/app/bd2server"]
CMD ["serve", "--listen", "0.0.0.0:8080", "--data-dir", "/app/data"]
CMD ["serve", "--listen", "0.0.0.0:8080", "--data-dir", "/app/data", "--authentication-config", "/app/data/config/authentication.json", "--resource-config", "/app/data/config/resources.json", "--game-config", "/app/data/config/game.json"]
-3
View File
@@ -2,8 +2,5 @@
!go/
!go/**
!versions.json
!authentication.json
!resources.json
!game.json
!schedules/
!schedules/**
+2 -2
View File
@@ -2,7 +2,7 @@
`game.json` 是服务端统一的游戏规则配置入口。后续可配置的游戏规则在此按功能分组扩展;认证、资源源、版本清单仍分别使用现有配置文件。
发布部署时,将文件放在 `bd2server.exe`(Linux/macOS 为 `bd2server`)旁。开发入口读取仓库根目录的同名文件。可修改的规则在重启后生效,起始章节只在首次初始化时选择。客户端无需配置此文件。发布包只在服务端包中提供 `game.json` 和本指南。
发布包不包含 `game.json`,首次运行时在 `bd2server.exe`(Linux/macOS 为 `bd2server`)旁生成默认文件。开发入口在 `.build/config/game.json` 生成并读取配置。已有文件不会覆盖,可修改的规则在重启后生效,起始章节只在首次初始化时选择。客户端无需配置此文件。
默认内容:
@@ -88,4 +88,4 @@
`schema_version` 当前必须为 `1`。字段拼写错误、未知字段、错误类型(例如字符串 `"true"`)、`null`、格式错误或文件末尾多出的 JSON 都会使服务端拒绝启动,并在错误中给出文件路径。省略 `gacha` 或此布尔开关时使用 `false`。
旧安装缺少整个文件时使用默认规则,服务端不会自动写入或覆盖文件。升级服务端时保留服主已经修改的 `game.json`;新发布包中的文件可用于比对新增字段。Docker 部署可将自定义文件只读挂载到 `/app/game.json`,重启容器后生效。
缺少文件时服务端会生成默认配置;已有文件保留,格式错误会明确报错。升级时保留服主修改过的配置,新增默认字段可参考本指南。Docker 镜像的默认命令在可写数据目录 `/app/data/config/` 生成和读取三个配置;自定义命令或只读挂载时通过 `--game-config` 指定路径,重启后生效。
+1 -1
View File
@@ -76,7 +76,7 @@ BD2 Client Studio 是使用 Wails 的独立桌面客户端设置工具。Windows
认证策略由 `bd2server.exe` 同目录的 `authentication.json` 权威决定。`mode=local`(默认)不依赖第三方登录,单人本地服保持不动,联机服可设为 `oauth` 来让玩家通过 Discord/Google 登录。
游戏规则统一使用服务端同目录的 `game.json`,修改后重启生效;开发入口读取仓库根目录的文件。联动 UR 专武是否加入 UR 必得装备券池及升级保留配置的方法见 [服务端游戏规则配置](GAME_CONFIGURATION.md)。
`authentication.json`、`resources.json` 和 `game.json` 不随发布包分发,首次运行时在服务端可执行文件旁生成默认文件,已有文件不会覆盖。开发入口使用 `.build/config/` 中的三个配置。游戏规则修改后重启生效,详细见 [服务端游戏规则配置](GAME_CONFIGURATION.md)。
服务端日志默认输出 INFO 及以上级别,仅终端启用颜色:TRACE 灰、DEBUG 青、INFO 绿、WARN 黄、ERROR 红。可用 `serve --log-level debug --log-color auto` 调整,或设置 `BD2_LOG_LEVEL` 和 `BD2_LOG_COLOR` 环境变量;显式命令行参数优先。级别支持 `trace/debug/info/warn/error`,颜色支持 `auto/always/never`。`auto` 下文件和重定向保持纯文本,`NO_COLOR` 或 `TERM=dumb` 也会关闭颜色;如需在 Docker 日志流中显示颜色,可显式选择 `always`。
-4
View File
@@ -1,4 +0,0 @@
{
"mode": "local",
"providers": {}
}
-6
View File
@@ -16,9 +16,6 @@ $serverGoDir = Join-Path $serverPackage 'go'
$serverStateDir = Join-Path $serverPackage 'data\state'
$clientPluginDir = Join-Path $clientPackage 'plugins'
$versionConfig = Join-Path $root 'versions.json'
$authenticationConfig = Join-Path $root 'authentication.json'
$resourceConfig = Join-Path $root 'resources.json'
$gameConfig = Join-Path $root 'game.json'
$scheduleDirectory = Join-Path $root 'schedules'
try {
@@ -143,9 +140,6 @@ foreach ($scheduleFile in $scheduleFiles) {
Copy-Item -LiteralPath $scheduleFile.FullName -Destination $serverScheduleDirectory -Force
}
Copy-Item -LiteralPath $versionConfig -Destination (Join-Path $serverPackage 'versions.json') -Force
Copy-Item -LiteralPath $authenticationConfig -Destination (Join-Path $serverPackage 'authentication.json') -Force
Copy-Item -LiteralPath $resourceConfig -Destination (Join-Path $serverPackage 'resources.json') -Force
Copy-Item -LiteralPath $gameConfig -Destination (Join-Path $serverPackage 'game.json') -Force
Copy-Item -LiteralPath $versionConfig -Destination (Join-Path $clientPackage 'versions.json') -Force
Copy-Item -LiteralPath (Join-Path $root 'RELEASE.md') -Destination (Join-Path $serverPackage 'README.md') -Force
Copy-Item -LiteralPath (Join-Path $root 'AUTHENTICATION.md') -Destination (Join-Path $serverPackage 'AUTHENTICATION.md') -Force
-16
View File
@@ -1,16 +0,0 @@
{
"schema_version": 1,
"story": {
"start_pack_id": 21
},
"gacha": {
"include_collaboration_ur_weapons": false
},
"purchases": {
"diamond_recharge": {
"currency": "free",
"gold_per_paid_diamond": 1000,
"diamonds_per_paid_diamond": 1
}
}
}
+4 -6
View File
@@ -23,9 +23,9 @@ func runDevelopmentCommand(args []string) (bool, error) {
}
serveArgs := append([]string(nil), args[2:]...)
serveArgs = appendDefaultFlag(serveArgs, "--version-config", filepath.Join(root, "versions.json"))
serveArgs = appendDefaultFlag(serveArgs, "--authentication-config", filepath.Join(root, "authentication.json"))
serveArgs = appendDefaultFlag(serveArgs, "--resource-config", filepath.Join(root, "resources.json"))
serveArgs = appendDefaultFlag(serveArgs, "--game-config", filepath.Join(root, "game.json"))
serveArgs = appendDefaultFlag(serveArgs, "--authentication-config", filepath.Join(root, ".build", "config", "authentication.json"))
serveArgs = appendDefaultFlag(serveArgs, "--resource-config", filepath.Join(root, ".build", "config", "resources.json"))
serveArgs = appendDefaultFlag(serveArgs, "--game-config", filepath.Join(root, ".build", "config", "game.json"))
serveArgs = appendDefaultFlag(serveArgs, "--data-dir", filepath.Join(root, "data"))
serveArgs = appendDefaultFlag(serveArgs, "--state", filepath.Join(root, "data", "state", "state.db"))
return true, serve(serveArgs)
@@ -38,9 +38,7 @@ func findDevelopmentRoot() (string, error) {
}
for directory := filepath.Clean(working); ; directory = filepath.Dir(directory) {
if regularDevelopmentFile(filepath.Join(directory, "versions.json")) &&
regularDevelopmentFile(filepath.Join(directory, "go", "go.mod")) &&
regularDevelopmentFile(filepath.Join(directory, "authentication.json")) &&
regularDevelopmentFile(filepath.Join(directory, "resources.json")) {
regularDevelopmentFile(filepath.Join(directory, "go", "go.mod")) {
return directory, nil
}
parent := filepath.Dir(directory)
+23 -1
View File
@@ -2,7 +2,29 @@
package main
import "testing"
import (
"os"
"path/filepath"
"testing"
)
func TestFindDevelopmentRootWithoutRuntimeConfigurations(t *testing.T) {
root := t.TempDir()
child := filepath.Join(root, "go", "cmd")
if err := os.MkdirAll(child, 0755); err != nil {
t.Fatal(err)
}
for _, path := range []string{filepath.Join(root, "versions.json"), filepath.Join(root, "go", "go.mod")} {
if err := os.WriteFile(path, nil, 0600); err != nil {
t.Fatal(err)
}
}
t.Chdir(child)
got, err := findDevelopmentRoot()
if err != nil || got != root {
t.Fatalf("development root = %q, %v", got, err)
}
}
func TestAppendDefaultFlagPreservesExplicitOverride(t *testing.T) {
for _, args := range [][]string{{"--data-dir", "custom"}, {"--data-dir=custom"}} {
+7
View File
@@ -12,6 +12,8 @@ import (
"path/filepath"
"strings"
"time"
"bd2server/internal/server/configfile"
)
const FileName = "authentication.json"
@@ -52,11 +54,16 @@ type Public struct {
Providers []string `json:"providers"`
}
func Default() Config { return Config{Mode: "local"} }
func Load(path string) (Config, error) {
path, err := filepath.Abs(filepath.Clean(path))
if err != nil {
return Config{}, fmt.Errorf("authconfig: resolve path: %w", err)
}
if err := configfile.Ensure(path, Default()); err != nil {
return Config{}, fmt.Errorf("authconfig: initialize %s: %w", path, err)
}
file, err := os.Open(path)
if err != nil {
return Config{}, fmt.Errorf("authconfig: open %s: %w", path, err)
+51
View File
@@ -0,0 +1,51 @@
// Package configfile publishes default configuration without replacing user files.
package configfile
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
)
// Ensure publishes a complete JSON file only when path does not exist.
// Linking a same-directory temporary file makes publication atomic and refuses
// to replace a configuration created by another process in the meantime.
func Ensure(path string, defaults any) error {
if _, err := os.Stat(path); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
data, err := json.MarshalIndent(defaults, "", " ")
if err != nil {
return err
}
if err = os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("create configuration directory: %w", err)
}
file, err := os.CreateTemp(filepath.Dir(path), ".config-*.tmp")
if err != nil {
return err
}
defer os.Remove(file.Name())
if _, err = file.Write(append(data, '\n')); err == nil {
err = file.Sync()
}
closeErr := file.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
if err = os.Link(file.Name(), path); errors.Is(err, os.ErrExist) {
return nil
} else if err != nil {
return fmt.Errorf("create default configuration %s: %w", path, err)
}
slog.Info("default configuration created", "path", path)
return nil
}
+6 -5
View File
@@ -9,6 +9,8 @@ import (
"io"
"os"
"path/filepath"
"bd2server/internal/server/configfile"
)
const FileName = "game.json"
@@ -48,13 +50,12 @@ func Default() Config {
return Config{SchemaVersion: 1, Story: StoryConfig{StartPackID: 21}, Purchases: PurchasesConfig{DiamondRecharge: DiamondRechargeConfig{Currency: "free", GoldPerPaidDiamond: 1000, DiamondsPerPaidDiamond: 1}}}
}
// Load reads one explicit path. Older installations without the file retain
// the default rules; an existing invalid file must never silently fall back.
// Load generates defaults when missing and strictly validates existing files.
func Load(path string) (Config, error) {
data, err := os.ReadFile(filepath.Clean(path))
if errors.Is(err, os.ErrNotExist) {
return Default(), nil
if err := configfile.Ensure(path, Default()); err != nil {
return Config{}, fmt.Errorf("game configuration: initialize %s: %w", path, err)
}
data, err := os.ReadFile(filepath.Clean(path))
if err != nil {
return Config{}, fmt.Errorf("game configuration: read %s: %w", path, err)
}
+3 -3
View File
@@ -74,14 +74,14 @@ func TestStartingChapterConfiguration(t *testing.T) {
}
}
func TestMissingFileDefaultsWithoutCreating(t *testing.T) {
func TestMissingFileCreatesDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), FileName)
cfg, err := Load(path)
if err != nil || cfg != Default() {
t.Fatalf("config = %+v, error = %v", cfg, err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("Load created missing config: %v", err)
if _, err := os.Stat(path); err != nil {
t.Fatalf("Load did not create missing config: %v", err)
}
}
@@ -1,69 +0,0 @@
package gamedata
import (
"os"
"testing"
)
// Opt-in performance verification, not a source for game design assertions.
func BenchmarkInstalledWaypointPackWarm(b *testing.B) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
b.Skip("BD2_REAL_GAMEDATA not configured")
}
if err := CloseDatabaseCache(); err != nil {
b.Fatal(err)
}
b.Cleanup(func() {
if err := CloseDatabaseCache(); err != nil {
b.Error(err)
}
})
if _, err := LoadWaypointPack(root, "20260923193640", 21); err != nil {
b.Fatal(err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := LoadWaypointPack(root, "20260923193640", 21); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkInstalledWaypointPackCold(b *testing.B) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
b.Skip("BD2_REAL_GAMEDATA not configured")
}
b.Cleanup(func() { _ = CloseDatabaseCache() })
for i := 0; i < b.N; i++ {
b.StopTimer()
if err := CloseDatabaseCache(); err != nil {
b.Fatal(err)
}
b.StartTimer()
if _, err := LoadWaypointPack(root, "20260923193640", 21); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkInstalledPackDetailWarm(b *testing.B) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
b.Skip("BD2_REAL_GAMEDATA not configured")
}
if err := CloseDatabaseCache(); err != nil {
b.Fatal(err)
}
b.Cleanup(func() { _ = CloseDatabaseCache() })
if _, err := LoadPackDetailDesign(root, "20260923193640", 21); err != nil {
b.Fatal(err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := LoadPackDetailDesign(root, "20260923193640", 21); err != nil {
b.Fatal(err)
}
}
}
@@ -11,6 +11,8 @@ import (
"os"
"path/filepath"
"strings"
"bd2server/internal/server/configfile"
)
const (
@@ -51,6 +53,9 @@ func Default() Config {
// Load reads one explicit resources.json. Unknown fields and trailing JSON are
// rejected so a misspelled URL key cannot silently fall back to another CDN.
func Load(path string) (Config, error) {
if err := configfile.Ensure(path, Default()); err != nil {
return Config{}, fmt.Errorf("resource policy: initialize %s: %w", path, err)
}
data, err := os.ReadFile(filepath.Clean(path))
if err != nil {
return Config{}, fmt.Errorf("resource policy: read %s: %w", path, err)
-3
View File
@@ -1,3 +0,0 @@
{
"mode": "official"
}