From 857381fad6d137cae04f48e685b888c8e567b705 Mon Sep 17 00:00:00 2001 From: Flechazo <2558755403@qq.com> Date: Sun, 4 Oct 2026 12:22:15 +0800 Subject: [PATCH] fix(server): persist master profile to restore saved login pack --- go/cmd/bd2server/main.go | 13 ++ go/internal/server/feature/feature.go | 1 - go/internal/server/feature/feature_test.go | 6 +- go/internal/server/feature/standalone.go | 1 - go/internal/server/player/profile.go | 170 +++++++++++++++++++++ go/internal/server/player/profile_test.go | 127 +++++++++++++++ 6 files changed, 313 insertions(+), 5 deletions(-) create mode 100644 go/internal/server/player/profile.go create mode 100644 go/internal/server/player/profile_test.go diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index aaf2b75..c7633ff 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -39,6 +39,7 @@ import ( "bd2server/internal/server/session" "bd2server/internal/server/transport" "bd2server/internal/server/versionconfig" + "bd2server/internal/server/wire" "bd2server/internal/server/world" ) @@ -570,6 +571,14 @@ func serve(args []string) (serveErr error) { if err := login.AttachFriendshipAP(friendshipService); err != nil { return err } + accountName, found, err := wire.Bytes(login.UserInfo, 2) + if err != nil || !found || len(accountName) == 0 { + return errors.New("account seed requires its existing display name for master title") + } + masterTitleService, err := player.OpenMasterTitleService(stateRepository, string(accountName)) + if err != nil { + return fmt.Errorf("load master title: %w", err) + } battleService := battle.NewService(gameData, *gameDataVersion, ownedItems, worldService.CurrentPackID) characters := worldService.CharacterService() battleService.AttachCommittedHealth(func(health map[uint64]uint64) error { @@ -622,6 +631,7 @@ func serve(args []string) (serveErr error) { costumePotentialService, costumeBurstService, friendshipService, + masterTitleService, foodService, starter, mailService, @@ -658,6 +668,9 @@ func serve(args []string) (serveErr error) { return fmt.Errorf("mark account initialization complete: %w", err) } } + if err := masterTitleService.EnsurePersisted(); err != nil { + return fmt.Errorf("persist master title: %w", err) + } problems, err := stateRepository.Validate() if err != nil { return fmt.Errorf("validate account state database: %w", err) diff --git a/go/internal/server/feature/feature.go b/go/internal/server/feature/feature.go index 1901877..4e4f55e 100644 --- a/go/internal/server/feature/feature.go +++ b/go/internal/server/feature/feature.go @@ -41,7 +41,6 @@ var emptyResponses = map[string]int{ "/GuildInitInfo": 332, "/IdCardPresetInfo": 450, "/LifeUserInfo": 594, - "/MasterTitleInfo": 590, "/MiniEventHubInfo": 534, "/MyLikeInfo": 218, "/MyRoomItemInfo": 232, diff --git a/go/internal/server/feature/feature_test.go b/go/internal/server/feature/feature_test.go index 0451bba..44b3cb7 100644 --- a/go/internal/server/feature/feature_test.go +++ b/go/internal/server/feature/feature_test.go @@ -8,8 +8,8 @@ import ( ) func TestHandleAuditedEmptyResponses(t *testing.T) { - if got := len(EmptyPacketCodes()); got != 31 { - t.Fatalf("audited empty-response registry has %d paths, want 31", got) + if got := len(EmptyPacketCodes()); got != 30 { + t.Fatalf("audited empty-response registry has %d paths, want 30", got) } for path, wantCode := range EmptyPacketCodes() { t.Run(path, func(t *testing.T) { @@ -28,7 +28,7 @@ func TestHandleRejectsUnknownAndInvalidRequests(t *testing.T) { if _, _, ok, err := Handle("/CharAwakeInfo", wire.AppendVarint(nil, 1, 1)); ok || err != nil { t.Fatalf("stateful CharAwakeInfo must not be handled by feature defaults: ok=%v err=%v", ok, err) } - for _, path := range []string{"/PresetInfo", "/DeckCostumeSettingInfo", "/FriendshipInfo"} { + for _, path := range []string{"/PresetInfo", "/DeckCostumeSettingInfo", "/FriendshipInfo", "/MasterTitleInfo", "/MasterTitleInfoUpdate"} { if _, _, ok, err := Handle(path, wire.AppendVarint(nil, 1, 1)); ok || err != nil { t.Fatalf("stateful %s must not be handled by feature defaults: ok=%v err=%v", path, ok, err) } diff --git a/go/internal/server/feature/standalone.go b/go/internal/server/feature/standalone.go index 5391eb1..5ea7b31 100644 --- a/go/internal/server/feature/standalone.go +++ b/go/internal/server/feature/standalone.go @@ -45,7 +45,6 @@ var commandSuccess = map[string]int{ "/SaveFieldCharControlDeckType": 288, "/WaypointSave": 32, "/ActiveMap": 0, - "/MasterTitleInfoUpdate": 592, "/FieldDeckSave": 274, "/CostumeUse": 41, "/BattleExit": 388, diff --git a/go/internal/server/player/profile.go b/go/internal/server/player/profile.go new file mode 100644 index 0000000..a51a5e1 --- /dev/null +++ b/go/internal/server/player/profile.go @@ -0,0 +1,170 @@ +package player + +import ( + "encoding/json" + "errors" + "math" + "time" + "unicode" + "unicode/utf8" + + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" +) + +type masterTitle struct { + Name string `json:"name"` + Month uint64 `json:"month"` + Day uint64 `json:"day"` +} + +// MasterTitleService persists the identity whose empty name sends IntroUI +// back to the tutorial even when LoginUser advertises another saved pack. +// It uses one entry in the existing progress domain, leaving its core intact. +type MasterTitleService struct { + store stateio.AtomicEntryStore + defaultTitle masterTitle +} + +func OpenMasterTitleService(store stateio.Store, defaultName string) (*MasterTitleService, error) { + entries, ok := store.(stateio.AtomicEntryStore) + if !ok || !validMasterTitleName(defaultName, false) { + return nil, errors.New("player: invalid master title service") + } + s := &MasterTitleService{store: entries, defaultTitle: masterTitle{Name: defaultName}} + if _, err := s.load(); err != nil { + return nil, err + } + return s, nil +} + +func validMasterTitleName(name string, fromRequest bool) bool { + if !utf8.ValidString(name) || len(name) == 0 || len(name) > 24 || (fromRequest && len(name) < 4) { + return false + } + for _, r := range name { + if unicode.IsSpace(r) || unicode.IsControl(r) { + return false + } + 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)) { + return false + } + } + } + return true +} + +func validMasterBirthday(month, day uint64) bool { + if month == 0 && day == 0 { + return true + } + if month < 1 || month > 12 || day < 1 || day > 31 { + return false + } + // The client explicitly uses leap year 2024 for birthday selection. + return uint64(time.Date(2024, time.Month(month)+1, 0, 0, 0, 0, 0, time.UTC).Day()) >= day +} + +func (s *MasterTitleService) load() (masterTitle, error) { + raw, found, err := s.store.LoadEntry("progress", "master_title", "identity") + if err != nil { + return masterTitle{}, err + } + if !found { + return s.defaultTitle, nil + } + if err := stateio.RequireExactJSONObject(raw, "name", "month", "day"); err != nil { + return masterTitle{}, err + } + var title masterTitle + if json.Unmarshal(raw, &title) != nil || !validMasterTitleName(title.Name, false) || !validMasterBirthday(title.Month, title.Day) { + return masterTitle{}, errors.New("player: invalid saved master title") + } + return title, nil +} + +// EnsurePersisted records the existing account display name once, with an +// unknown birthday. Startup calls it after progress core initialization. +func (s *MasterTitleService) EnsurePersisted() error { + _, found, err := s.store.LoadEntry("progress", "master_title", "identity") + if err != nil { + return err + } + if found { + _, err = s.load() + return err + } + core, err := s.store.Load("progress") + if err != nil { + return err + } + if len(core) == 0 { + return errors.New("player: master title requires initialized progress") + } + payload, err := json.Marshal(s.defaultTitle) + if err != nil { + return err + } + return s.store.SaveWithEntries("progress", nil, []stateio.EntryMutation{{Bucket: "master_title", Key: "identity", Payload: payload}}) +} + +func (s *MasterTitleService) Handle(path string, request []byte) (int, []byte, bool, error) { + if path != "/MasterTitleInfo" && path != "/MasterTitleInfoUpdate" { + return 0, nil, false, nil + } + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 || seq > math.MaxInt32 { + return 0, nil, true, errors.New("player: invalid master title sequence") + } + if path == "/MasterTitleInfo" { + title, err := s.load() + if err != nil { + return 0, nil, true, err + } + body := wire.AppendString(nil, 1, title.Name) + if title.Month != 0 { + body = wire.AppendVarint(body, 2, title.Month) + } + if title.Day != 0 { + body = wire.AppendVarint(body, 3, title.Day) + } + return 590, body, true, nil + } + rawName, found, err := wire.Bytes(request, 2) + if err != nil || !found || !validMasterTitleName(string(rawName), true) { + return 0, nil, true, errors.New("player: invalid master title name") + } + month, _, err := wire.Varint(request, 3) + if err != nil { + return 0, nil, true, err + } + day, _, err := wire.Varint(request, 4) + if err != nil || !validMasterBirthday(month, day) { + return 0, nil, true, errors.New("player: invalid master title birthday") + } + next := masterTitle{Name: string(rawName), Month: month, Day: day} + current, err := s.load() + if err != nil { + return 0, nil, true, err + } + if current == next { + return 592, nil, true, nil + } + core, err := s.store.Load("progress") + if err != nil { + return 0, nil, true, err + } + if len(core) == 0 { + return 0, nil, true, errors.New("player: master title requires initialized progress") + } + payload, err := json.Marshal(next) + if err != nil { + return 0, nil, true, err + } + if err := s.store.SaveWithEntries("progress", nil, []stateio.EntryMutation{{Bucket: "master_title", Key: "identity", Payload: payload}}); err != nil { + return 0, nil, true, err + } + return 592, nil, true, nil +} diff --git a/go/internal/server/player/profile_test.go b/go/internal/server/player/profile_test.go new file mode 100644 index 0000000..4652594 --- /dev/null +++ b/go/internal/server/player/profile_test.go @@ -0,0 +1,127 @@ +package player + +import ( + "bytes" + "errors" + "testing" + + "bd2server/internal/server/progress" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" +) + +func newMasterProfile(t *testing.T, store stateio.Store) (*MasterTitleService, []byte) { + t.Helper() + p, err := progress.OpenStore(store) + if err != nil { + t.Fatal(err) + } + if err := p.EnsurePersisted(); err != nil { + t.Fatal(err) + } + core, err := store.Load("progress") + if err != nil { + t.Fatal(err) + } + s, err := OpenMasterTitleService(store, "Guest_30738202") + if err != nil { + t.Fatal(err) + } + if err := s.EnsurePersisted(); err != nil { + t.Fatal(err) + } + return s, core +} + +func TestMasterTitleSurvivesRestartAndKeepsUnknownBirthday(t *testing.T) { + store := stateio.NewMemory() + s, core := newMasterProfile(t, store) + request := wire.AppendVarint(nil, 1, 1) + code, body, handled, err := s.Handle("/MasterTitleInfo", request) + if err != nil || !handled || code != 590 { + t.Fatalf("profile code=%d handled=%v err=%v", code, handled, err) + } + name, found, err := wire.Bytes(body, 1) + if err != nil || !found || string(name) != "Guest_30738202" { + t.Fatal("profile name is empty, so IntroUI would force tutorial") + } + for _, field := range []int{2, 3} { + if _, found, _ := wire.Varint(body, field); found { + t.Fatal("unknown birthday was invented") + } + } + update := wire.AppendString(wire.AppendVarint(nil, 1, 2), 2, "玩家名字") + update = wire.AppendVarint(wire.AppendVarint(update, 3, 2), 4, 29) + if code, body, handled, err := s.Handle("/MasterTitleInfoUpdate", update); err != nil || !handled || code != 592 || len(body) != 0 { + t.Fatalf("update %d %v %v", code, handled, err) + } + if _, _, _, err := s.Handle("/MasterTitleInfoUpdate", update); err != nil { + t.Fatal("idempotent set failed", err) + } + s, err = OpenMasterTitleService(store, "anotherDefault") + if err != nil { + t.Fatal(err) + } + _, body, _, err = s.Handle("/MasterTitleInfo", request) + if err != nil { + t.Fatal(err) + } + name, _, _ = wire.Bytes(body, 1) + month, _, _ := wire.Varint(body, 2) + day, _, _ := wire.Varint(body, 3) + if string(name) != "玩家名字" || month != 2 || day != 29 { + t.Fatalf("restart profile name=%s birthday=%d/%d", name, month, day) + } + after, _ := store.Load("progress") + if !bytes.Equal(core, after) { + t.Fatal("profile modified progress core") + } +} + +func TestMasterTitleRejectsInvalidRequestWithoutWriting(t *testing.T) { + store := stateio.NewMemory() + s, _ := newMasterProfile(t, store) + before, _, _ := store.LoadEntry("progress", "master_title", "identity") + for _, request := range [][]byte{nil, wire.AppendVarint(nil, 1, 0), wire.AppendString(wire.AppendVarint(nil, 1, 1), 2, ""), wire.AppendString(wire.AppendVarint(nil, 1, 1), 2, "Bad Name"), wire.AppendString(wire.AppendVarint(nil, 1, 1), 2, "ab"), wire.AppendVarint(wire.AppendVarint(wire.AppendString(wire.AppendVarint(nil, 1, 1), 2, "Player"), 3, 2), 4, 30)} { + if _, _, handled, err := s.Handle("/MasterTitleInfoUpdate", request); !handled || err == nil { + t.Fatal("invalid profile accepted") + } + } + if _, _, _, err := s.Handle("/MasterTitleInfo", nil); err == nil { + t.Fatal("missing info sequence accepted") + } + after, _, _ := store.LoadEntry("progress", "master_title", "identity") + if !bytes.Equal(before, after) { + t.Fatal("invalid profile request wrote state") + } +} + +type masterWriteFailure struct { + *stateio.Memory + fail bool +} + +func (s *masterWriteFailure) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error { + if s.fail { + return errors.New("profile write failure") + } + return s.Memory.SaveWithEntries(domain, core, changes) +} + +func TestMasterTitleWriteFailureLeavesPriorResponse(t *testing.T) { + store := &masterWriteFailure{Memory: stateio.NewMemory()} + s, _ := newMasterProfile(t, store) + store.fail = true + update := wire.AppendString(wire.AppendVarint(nil, 1, 1), 2, "ChangedName") + if _, _, _, err := s.Handle("/MasterTitleInfoUpdate", update); err == nil { + t.Fatal("write error ignored") + } + _, body, _, err := s.Handle("/MasterTitleInfo", wire.AppendVarint(nil, 1, 2)) + if err != nil { + t.Fatal(err) + } + name, _, _ := wire.Bytes(body, 1) + if string(name) != "Guest_30738202" { + t.Fatal("failed write changed profile") + } +}