feat(all): add verified gacha flows and native client studio

This commit is contained in:
2026-09-30 23:49:00 +08:00
parent addc394cae
commit bf4f259bad
66 changed files with 2387 additions and 2351 deletions
+40 -4
View File
@@ -3,17 +3,23 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
clientconfig "bd2server/internal/client/config"
clientlayout "bd2server/internal/client/layout"
)
type clientDevelopmentConfig struct {
SchemaVersion int `json:"schema_version"`
GameDirectory string `json:"game_directory"`
}
func developmentRunOptions(args []string) ([]string, clientRunOptions, error) {
if len(args) == 0 || args[0] != "--dev" {
return args, clientRunOptions{}, nil
@@ -31,10 +37,11 @@ func developmentRunOptions(args []string) ([]string, clientRunOptions, error) {
return nil, clientRunOptions{}, err
}
if gameDir == "" {
preferences, preferenceErr := clientconfig.LoadPreferences()
if preferenceErr == nil {
gameDir = preferences.GameDirectory
config, configErr := loadClientDevelopmentConfig(filepath.Join(root, "go", "config.json"))
if configErr != nil {
return nil, clientRunOptions{}, configErr
}
gameDir = config.GameDirectory
}
if gameDir != "" {
if err := buildDevelopmentPlugins(root, gameDir); err != nil {
@@ -49,6 +56,35 @@ func developmentRunOptions(args []string) ([]string, clientRunOptions, error) {
}, nil
}
func loadClientDevelopmentConfig(path string) (clientDevelopmentConfig, error) {
data, err := os.ReadFile(filepath.Clean(path))
if errors.Is(err, os.ErrNotExist) {
return clientDevelopmentConfig{}, fmt.Errorf("development config %s is missing; copy go/config.example.json to go/config.json or pass --game-dir", path)
}
if err != nil {
return clientDevelopmentConfig{}, fmt.Errorf("read development config: %w", err)
}
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
var config clientDevelopmentConfig
if err := decoder.Decode(&config); err != nil {
return clientDevelopmentConfig{}, fmt.Errorf("decode development config: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return clientDevelopmentConfig{}, errors.New("development config must contain exactly one JSON object")
}
if config.SchemaVersion != 1 || strings.TrimSpace(config.GameDirectory) == "" {
return clientDevelopmentConfig{}, errors.New("development config requires schema_version 1 and game_directory")
}
absolute, err := filepath.Abs(filepath.Clean(config.GameDirectory))
if err != nil {
return clientDevelopmentConfig{}, fmt.Errorf("resolve development game directory: %w", err)
}
config.GameDirectory = absolute
return config, nil
}
func findClientDevelopmentRoot() (string, error) {
working, err := os.Getwd()
if err != nil {
+47 -6
View File
@@ -3,6 +3,8 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
@@ -15,7 +17,7 @@ func TestClientDevelopmentGameDirectory(t *testing.T) {
}{
{name: "separate", args: []string{"--game-dir", filepath.Join("some", "game")}, want: filepath.Join("some", "game")},
{name: "equals", args: []string{"--game-dir=" + filepath.Join("other", "game")}, want: filepath.Join("other", "game")},
{name: "absent", args: []string{"--no-browser"}},
{name: "absent", args: nil},
} {
t.Run(test.name, func(t *testing.T) {
got, err := clientDevelopmentGameDirectory(test.args)
@@ -37,14 +39,53 @@ func TestClientDevelopmentGameDirectoryRequiresValue(t *testing.T) {
}
}
func TestDevelopmentRunOptionsUsesRepositoryFiles(t *testing.T) {
t.Setenv("APPDATA", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
args, options, err := developmentRunOptions([]string{"--dev", "run", "--no-browser"})
func TestLoadClientDevelopmentConfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
gameDir := filepath.Join(dir, "BrownDust II")
quoted, err := json.Marshal(gameDir)
if err != nil {
t.Fatal(err)
}
if len(args) != 1 || args[0] != "--no-browser" {
if err := os.WriteFile(path, []byte(`{"schema_version":1,"game_directory":`+string(quoted)+`}`), 0o600); err != nil {
t.Fatal(err)
}
config, err := loadClientDevelopmentConfig(path)
if err != nil {
t.Fatal(err)
}
want, _ := filepath.Abs(gameDir)
if config.SchemaVersion != 1 || config.GameDirectory != want {
t.Fatalf("config=%+v want directory %q", config, want)
}
if _, err := loadClientDevelopmentConfig(filepath.Join(dir, "missing.json")); err == nil {
t.Fatal("missing development config accepted")
}
for name, body := range map[string]string{
"unknown": `{"schema_version":1,"game_directory":"x","extra":true}`,
"version": `{"schema_version":2,"game_directory":"x"}`,
"empty": `{"schema_version":1,"game_directory":""}`,
"trailing": `{"schema_version":1,"game_directory":"x"}{}`,
} {
t.Run(name, func(t *testing.T) {
bad := filepath.Join(dir, name+".json")
if err := os.WriteFile(bad, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadClientDevelopmentConfig(bad); err == nil {
t.Fatal("invalid development config accepted")
}
})
}
}
func TestDevelopmentRunOptionsUsesRepositoryFiles(t *testing.T) {
gameDir := t.TempDir()
args, options, err := developmentRunOptions([]string{"--dev", "run", "--game-dir", gameDir})
if err != nil {
t.Fatal(err)
}
if len(args) != 2 || args[0] != "--game-dir" || args[1] != gameDir {
t.Fatalf("client args = %v", args)
}
for name, path := range map[string]string{
-4
View File
@@ -30,8 +30,6 @@ func main() {
func runClient(args []string, options clientRunOptions) error {
fs := flag.NewFlagSet("bd2client", flag.ContinueOnError)
listen := fs.String("listen", "127.0.0.1:0", "loopback address for the local setup interface")
noBrowser := fs.Bool("no-browser", false, "print the interface URL without opening a browser")
gameDir := fs.String("game-dir", "", "initial Brown Dust II installation directory")
if err := fs.Parse(args); err != nil {
return err
@@ -66,8 +64,6 @@ func runClient(args []string, options clientRunOptions) error {
}
}
if err := clientapp.Run(clientapp.Options{
Listen: *listen,
NoBrowser: *noBrowser,
InitialGameDir: *gameDir,
Logger: logger,
LogPath: logPath,
+10 -1
View File
@@ -213,6 +213,10 @@ func serve(args []string) (serveErr error) {
if err != nil {
return fmt.Errorf("load active gacha GameData: %w", err)
}
firstGacha, err := gamedata.LoadFirstGacha(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load first gacha GameData: %w", err)
}
stateRepository, err := accountstate.Open(filepath.Clean(*stateFile))
if err != nil {
return fmt.Errorf("open account state database: %w", err)
@@ -426,7 +430,12 @@ func serve(args []string) (serveErr error) {
}
// The mapped client property is IsDoneFirstGachaPick. Its authoritative
// local state is the explicit GachaSubType=3 completion marker.
login.SetFirstGacha(gachaService.FirstGachaCompleted())
if err := login.AttachFirstGacha(gachaService); err != nil {
return fmt.Errorf("attach first gacha status to login: %w", err)
}
if err := gachaService.AttachFirstGacha(firstGacha); err != nil {
return fmt.Errorf("attach first gacha GameData: %w", err)
}
gachaService.AttachInventory(ownedItems)
gachaService.AttachEquipmentGacha(equipmentGacha, ownedEquipment)
gachaService.AttachPreviewMission(func() error {