feat(all): add resilient server recovery and talent upgrades

This commit is contained in:
2026-10-03 15:09:31 +08:00
parent db1eab764e
commit ce03fe7289
70 changed files with 3664 additions and 419 deletions
+4 -2
View File
@@ -52,6 +52,8 @@ type Response struct {
type InitialState struct {
Platform string `json:"platform"`
ClientVersion string `json:"client_version"`
GameVersion string `json:"game_version"`
LogPath string `json:"log_path"`
GameDirectory string `json:"game_directory"`
ServerOrigin string `json:"server_origin"`
@@ -118,7 +120,7 @@ func (s *Studio) log() *slog.Logger {
func (s *Studio) Initialize() InitialState {
s.mu.Lock()
defer s.mu.Unlock()
state := InitialState{Platform: runtime.GOOS, LogPath: s.options.LogPath, GameDirectory: s.options.InitialGameDir, ServerOrigin: "http://127.0.0.1:8080", CDNMode: clientconfig.CDNOfficial}
state := InitialState{Platform: runtime.GOOS, ClientVersion: s.options.Versions.ClientVersion, GameVersion: s.options.Versions.GameVersion, LogPath: s.options.LogPath, GameDirectory: s.options.InitialGameDir, ServerOrigin: "http://127.0.0.1:8080", CDNMode: clientconfig.CDNOfficial}
if state.GameDirectory == "" {
return state
}
@@ -318,7 +320,7 @@ func (s *Studio) Launch(input Request) (Response, error) {
}
return Response{}, fmt.Errorf("launch Brown Dust II: %w", err)
}
s.log().Info("game launch requested", "platform", installation.Kind, "client_version", status.ClientVersion)
s.log().Info("game launch requested", "platform", installation.Kind, "client_version", s.options.Versions.ClientVersion, "game_version", status.ClientVersion)
return success("Brown Dust II started", nil), nil
})
}
+4 -4
View File
@@ -42,7 +42,7 @@ func TestEmbeddedStudioUsesNativeBindings(t *testing.T) {
func TestStudioInitializeLoadsSavedSettings(t *testing.T) {
dir := makeTestClient(t)
studio := NewStudio(Options{InitialGameDir: dir, Versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}}, NativeHost{})
studio := NewStudio(Options{InitialGameDir: dir, Versions: clientconfig.ReleaseVersions{GameVersion: "2.35.10", ClientVersion: "2.35.10+client.0.1.0"}}, NativeHost{})
if state := studio.Initialize(); state.AutoOpen {
t.Fatal("missing settings enabled automatic workspace")
}
@@ -50,14 +50,14 @@ func TestStudioInitializeLoadsSavedSettings(t *testing.T) {
t.Fatal(err)
}
state := studio.Initialize()
if !state.AutoOpen || state.GameDirectory != dir || state.ServerOrigin != "https://play.example.com" {
if !state.AutoOpen || state.GameDirectory != dir || state.ServerOrigin != "https://play.example.com" || state.ClientVersion != "2.35.10+client.0.1.0" || state.GameVersion != "2.35.10" {
t.Fatalf("state=%+v", state)
}
}
func TestStudioSaveRemembersOnlyValidatedSettings(t *testing.T) {
dir := makeTestClient(t)
studio := NewStudio(Options{Versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}}, NativeHost{})
studio := NewStudio(Options{Versions: clientconfig.ReleaseVersions{GameVersion: "2.35.10"}}, NativeHost{})
var remembered string
studio.savePreferences = func(value string) error { remembered = value; return nil }
input := Request{GameDirectory: dir, ServerOrigin: "https://play.example.com", CDNMode: clientconfig.CDNOfficial}
@@ -116,7 +116,7 @@ func TestStudioInitializeDefaultsWithoutGameDirectory(t *testing.T) {
func TestStudioBrowseUsesNativeDirectoryPicker(t *testing.T) {
dir := makeTestClient(t)
var title string
studio := NewStudio(Options{Versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}}, NativeHost{
studio := NewStudio(Options{Versions: clientconfig.ReleaseVersions{GameVersion: "2.35.10"}}, NativeHost{
BrowseDirectory: func(_ context.Context, requested string) (string, error) {
title = requested
return dir, nil
File diff suppressed because one or more lines are too long
+19 -5
View File
@@ -14,15 +14,18 @@ import (
const ReleaseFileName = "versions.json"
var (
clientVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`)
gameVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`)
releaseVersionPattern = regexp.MustCompile(`^([0-9]+\.[0-9]+\.[0-9]+)\+(client|server)\.([0-9]+\.[0-9]+\.[0-9]+)$`)
resourceVersionPattern = regexp.MustCompile(`^[0-9]{14}$`)
)
// ReleaseVersions is the exact client/resource tuple supported by one
// bd2client distribution. The release package carries the authoritative
// versions.json next to bd2client.exe.
// ReleaseVersions separates the official game compatibility version from the
// independently evolving client and server software release versions. The
// release package carries the authoritative versions.json beside bd2client.
type ReleaseVersions 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"`
@@ -48,9 +51,15 @@ func LoadReleaseVersions(path string) (ReleaseVersions, error) {
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return ReleaseVersions{}, errors.New("client release versions must contain exactly one JSON object")
}
if !clientVersionPattern.MatchString(versions.ClientVersion) {
if !gameVersionPattern.MatchString(versions.GameVersion) {
return ReleaseVersions{}, fmt.Errorf("invalid game_version %q", versions.GameVersion)
}
if !validReleaseVersion(versions.ClientVersion, versions.GameVersion, "client") {
return ReleaseVersions{}, fmt.Errorf("invalid client_version %q", versions.ClientVersion)
}
if !validReleaseVersion(versions.ServerVersion, versions.GameVersion, "server") {
return ReleaseVersions{}, fmt.Errorf("invalid server_version %q", versions.ServerVersion)
}
if !resourceVersionPattern.MatchString(versions.BundleVersion) || !resourceVersionPattern.MatchString(versions.GameDataVersion) {
return ReleaseVersions{}, errors.New("bundle_version and game_data_version must be 14-digit timestamps")
}
@@ -61,6 +70,11 @@ func LoadReleaseVersions(path string) (ReleaseVersions, error) {
return versions, nil
}
func validReleaseVersion(value, game, component string) bool {
match := releaseVersionPattern.FindStringSubmatch(value)
return len(match) == 4 && match[1] == game && match[2] == component
}
func ReleaseVersionsBesideExecutable() (ReleaseVersions, error) {
executable, err := os.Executable()
if err != nil {
+5 -4
View File
@@ -8,7 +8,7 @@ import (
func TestLoadReleaseVersions(t *testing.T) {
path := filepath.Join(t.TempDir(), ReleaseFileName)
data := `{"client_version":"2.35.10","game_data_version":"20260923193640","bundle_version":"20260921135230","seed_directory":"go/seed/v2_35_10","plugins":{"local_identity":"0.6.0","capture_environment":"0.2.0","login_ui":"0.1.0"}}`
data := `{"game_version":"2.35.10","client_version":"2.35.10+client.0.1.0","server_version":"2.35.10+server.0.1.0","game_data_version":"20260923193640","bundle_version":"20260921135230","seed_directory":"go/seed/v2_35_10","plugins":{"local_identity":"0.6.0","capture_environment":"0.2.0","login_ui":"0.1.0"}}`
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
@@ -16,15 +16,16 @@ func TestLoadReleaseVersions(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if got.ClientVersion != "2.35.10" || got.BundleVersion != "20260921135230" || got.GameDataVersion != "20260923193640" {
if got.GameVersion != "2.35.10" || got.ClientVersion != "2.35.10+client.0.1.0" || got.ServerVersion != "2.35.10+server.0.1.0" || got.BundleVersion != "20260921135230" || got.GameDataVersion != "20260923193640" {
t.Fatalf("versions=%+v", got)
}
}
func TestLoadReleaseVersionsRejectsUnknownAndTrailingData(t *testing.T) {
for name, data := range map[string]string{
"unknown": `{"client_version":"2.35.10","unknown":true}`,
"trailing": `{}` + `{}`,
"unknown": `{"game_version":"2.35.10","unknown":true}`,
"wrong-component": `{"game_version":"2.35.10","client_version":"2.35.10+server.0.1.0","server_version":"2.35.10+server.0.1.0"}`,
"trailing": `{}` + `{}`,
} {
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), ReleaseFileName)
+2 -2
View File
@@ -69,8 +69,8 @@ func Inspect(gameDir string, versions clientconfig.ReleaseVersions) (GameStatus,
if err != nil {
return status, err
}
if status.ClientVersion != versions.ClientVersion {
return status, fmt.Errorf("unsupported Brown Dust II client version %s; this bd2client release requires %s", status.ClientVersion, versions.ClientVersion)
if status.ClientVersion != versions.GameVersion {
return status, fmt.Errorf("unsupported Brown Dust II client version %s; this bd2client release requires %s", status.ClientVersion, versions.GameVersion)
}
if verified, verifyErr := introdb.VerifyClient(installation.Root); verifyErr == nil {
status.PatchedURL = verified.URL
+1 -1
View File
@@ -14,7 +14,7 @@ import (
func testVersions() clientconfig.ReleaseVersions {
return clientconfig.ReleaseVersions{
ClientVersion: "2.35.10", BundleVersion: "20260921135230", GameDataVersion: "20260923193640",
GameVersion: "2.35.10", ClientVersion: "2.35.10+client.0.1.0", ServerVersion: "2.35.10+server.0.1.0", BundleVersion: "20260921135230", GameDataVersion: "20260923193640",
}
}