fix(server): persist master profile to restore saved login pack

This commit is contained in:
2026-10-04 12:22:15 +08:00
parent c29204dcb0
commit 857381fad6
6 changed files with 313 additions and 5 deletions
+13
View File
@@ -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)
-1
View File
@@ -41,7 +41,6 @@ var emptyResponses = map[string]int{
"/GuildInitInfo": 332,
"/IdCardPresetInfo": 450,
"/LifeUserInfo": 594,
"/MasterTitleInfo": 590,
"/MiniEventHubInfo": 534,
"/MyLikeInfo": 218,
"/MyRoomItemInfo": 232,
+3 -3
View File
@@ -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)
}
-1
View File
@@ -45,7 +45,6 @@ var commandSuccess = map[string]int{
"/SaveFieldCharControlDeckType": 288,
"/WaypointSave": 32,
"/ActiveMap": 0,
"/MasterTitleInfoUpdate": 592,
"/FieldDeckSave": 274,
"/CostumeUse": 41,
"/BattleExit": 388,
+170
View File
@@ -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
}
+127
View File
@@ -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")
}
}