fix(server): restore saved login destination and arena field position

This commit is contained in:
2026-10-04 11:35:14 +08:00
parent d585172514
commit faca42e85a
11 changed files with 520 additions and 2 deletions
@@ -0,0 +1,39 @@
package account
import (
"errors"
"testing"
"bd2server/internal/server/wire"
)
type lastPackFixture struct {
pack uint64
err error
}
func (f *lastPackFixture) LastPlayedPackID() (uint64, error) { return f.pack, f.err }
func TestLoginUsesSavedPackAndPreservesSeedOnlyForNewAccount(t *testing.T) {
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 4, 21)}
provider := &lastPackFixture{}
if err := seed.AttachLastPlayedPack(provider); err != nil {
t.Fatal(err)
}
for _, tc := range []struct{ saved, want uint64 }{{0, 21}, {3001, 3001}, {22, 22}} {
provider.pack = tc.saved
body, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, _, _ := wire.Bytes(body, 1)
pack, _, _ := wire.Varint(user, 4)
if pack != tc.want {
t.Fatalf("saved=%d login pack=%d want=%d", tc.saved, pack, tc.want)
}
}
provider.err = errors.New("position unavailable")
if _, err := seed.Login(nil, []byte("0123456789abcdef0123456789abcdef")); err == nil {
t.Fatal("ignored saved position error")
}
}
+26
View File
@@ -38,6 +38,21 @@ type LoginSeed struct {
inventorySlots InventorySlotProvider
firstGacha FirstGachaProvider
friendshipAP FriendshipAPProvider
lastPlayedPack LastPlayedPackProvider
}
// LastPlayedPackProvider reads the persisted return destination. A zero value
// means there is no saved destination yet, so a new account uses its seed.
type LastPlayedPackProvider interface {
LastPlayedPackID() (uint64, error)
}
func (s *LoginSeed) AttachLastPlayedPack(provider LastPlayedPackProvider) error {
if provider == nil {
return errors.New("account: missing last played pack provider")
}
s.lastPlayedPack = provider
return nil
}
// FirstGachaProvider reads the mutable account flag for every login, including
@@ -339,6 +354,17 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) {
return nil, fmt.Errorf("account: session key: %w", err)
}
user := append([]byte(nil), s.UserInfo...)
if s.lastPlayedPack != nil {
packID, err := s.lastPlayedPack.LastPlayedPackID()
if err != nil {
return nil, fmt.Errorf("account: last played pack: %w", err)
}
if packID > 0 {
if user, _, err = wire.ReplaceVarint(user, 4, packID); err != nil {
return nil, fmt.Errorf("account: replace last played pack: %w", err)
}
}
}
if s.currencies != nil {
gold, freeJewelry, jewelry, mileage := s.currencies.Currencies()
var err error
+128
View File
@@ -0,0 +1,128 @@
package gamedata
import (
"database/sql"
"fmt"
)
// FieldPack describes non-story arena fields. PackType comes from the protocol
// enum; IDs and entry restrictions come from the installed GameData tables.
type FieldPack struct {
ID, Type, BuyPrice, UseSchedule uint64
TicketID, SquadLevel uint64
HasOpenRule bool
MapIDs map[int]bool
}
func LoadFieldPacks(root, version string) (map[int]FieldPack, error) {
db, closeDB, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer closeDB()
return loadFieldPacks(db)
}
func loadFieldPacks(db *sql.DB) (map[int]FieldPack, error) {
result := map[int]FieldPack{}
rows, err := db.Query("SELECT id,ProtoBuf FROM PackTable ORDER BY id")
if err != nil {
return nil, err
}
for rows.Next() {
var id int
var raw []byte
if err := rows.Scan(&id, &raw); err != nil {
rows.Close()
return nil, err
}
values := map[int]uint64{}
for _, field := range []int{7, 25, 55, 65} {
v, e := packedInts(raw, field)
if e != nil || len(v) > 1 {
rows.Close()
return nil, fmt.Errorf("gamedata: invalid arena pack %d field %d", id, field)
}
if len(v) == 1 {
values[field] = v[0]
}
}
if values[55] != 3 && values[55] != 10 {
continue
}
if id <= 0 || values[25] != uint64(id) {
rows.Close()
return nil, fmt.Errorf("gamedata: invalid field pack identity %d", id)
}
result[id] = FieldPack{ID: uint64(id), Type: values[55], BuyPrice: values[7], UseSchedule: values[65], MapIDs: map[int]bool{}}
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
rows, err = db.Query("SELECT id,ProtoBuf FROM ContentOpenTable WHERE groupId=1 ORDER BY id")
if err != nil {
return nil, err
}
for rows.Next() {
var id int
var raw []byte
if err := rows.Scan(&id, &raw); err != nil {
rows.Close()
return nil, err
}
pack, exists := result[id]
if !exists {
continue
}
pack.HasOpenRule = true
for field, target := range map[int]*uint64{5: &pack.SquadLevel, 6: &pack.TicketID} {
v, e := packedInts(raw, field)
if e != nil || len(v) > 1 {
rows.Close()
return nil, fmt.Errorf("gamedata: invalid field pack opening %d", id)
}
if len(v) == 1 {
*target = v[0]
}
}
result[id] = pack
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
rows, err = db.Query("SELECT id,packId FROM MapTable ORDER BY id")
if err != nil {
return nil, err
}
for rows.Next() {
var id, packID int
if err := rows.Scan(&id, &packID); err != nil {
rows.Close()
return nil, err
}
if pack, exists := result[packID]; exists {
pack.MapIDs[id] = true
}
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
for id, pack := range result {
if len(pack.MapIDs) == 0 {
return nil, fmt.Errorf("gamedata: arena pack %d has no map", id)
}
}
return result, nil
}
@@ -0,0 +1,59 @@
package gamedata
import (
"database/sql"
"testing"
"bd2server/internal/server/wire"
)
func TestFieldPacksLoadMetadataMapsAndEntryRestrictions(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
for _, query := range []string{"CREATE TABLE PackTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE ContentOpenTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)", "CREATE TABLE MapTable(id INTEGER,packId INTEGER)"} {
if _, err := db.Exec(query); err != nil {
t.Fatal(err)
}
}
for _, row := range []struct{ id, typ uint64 }{{901, 3}, {902, 10}, {903, 0}} {
proto := wire.AppendVarint(wire.AppendVarint(nil, 25, row.id), 55, row.typ)
if row.id == 902 {
proto = wire.AppendVarint(proto, 7, 20)
proto = wire.AppendVarint(proto, 65, 1)
}
if _, err := db.Exec("INSERT INTO PackTable VALUES(?,?)", row.id, proto); err != nil {
t.Fatal(err)
}
}
opening := wire.AppendVarint(wire.AppendVarint(nil, 5, 6), 6, 555)
if _, err := db.Exec("INSERT INTO ContentOpenTable VALUES(1,901,?)", opening); err != nil {
t.Fatal(err)
}
if _, err := db.Exec("INSERT INTO MapTable VALUES(9011,901),(9012,901),(9021,902),(9031,903)"); err != nil {
t.Fatal(err)
}
packs, err := loadFieldPacks(db)
if err != nil {
t.Fatal(err)
}
first := packs[901]
if first.ID != 901 || first.Type != 3 || first.TicketID != 555 || first.SquadLevel != 6 || !first.HasOpenRule || !first.MapIDs[9011] || first.MapIDs[9031] {
t.Fatalf("arena metadata=%+v", first)
}
second := packs[902]
if second.BuyPrice != 20 || second.UseSchedule != 1 || len(second.MapIDs) != 1 {
t.Fatalf("other arena=%+v", second)
}
if _, found := packs[903]; found {
t.Fatal("story loaded as arena")
}
if _, err := db.Exec("DELETE FROM MapTable WHERE packId=902"); err != nil {
t.Fatal(err)
}
if _, err := loadFieldPacks(db); err == nil {
t.Fatal("arena without valid map accepted")
}
}
@@ -44,6 +44,31 @@ func TestStorePersistsProgressAcrossRestart(t *testing.T) {
}
}
func TestLastPlayedPackTracksPersistedPositionAcrossRestart(t *testing.T) {
storage := stateio.NewMemory()
store, err := OpenStore(storage)
if err != nil {
t.Fatal(err)
}
if id, err := store.LastPlayedPackID(); err != nil || id != 0 {
t.Fatalf("new account pack=%d err=%v", id, err)
}
for _, pack := range []uint64{21, 3001} {
request := wire.AppendVarint(nil, 2, pack)
request = wire.AppendString(request, 3, `{"MapId":30011,"PlayerPosition":{"x":1,"y":2,"z":3}}`)
if err := store.SaveUserPosition(request); err != nil {
t.Fatal(err)
}
store, err = OpenStore(storage)
if err != nil {
t.Fatal(err)
}
if id, err := store.LastPlayedPackID(); err != nil || id != pack {
t.Fatalf("restored pack=%d want=%d err=%v", id, pack, err)
}
}
}
func TestStoreKeepsPackOverlap(t *testing.T) {
storage := stateio.NewMemory()
initial := `{"version":2,"position":{"PackID":0,"Position":{"MapId":0,"PlayerPosition":{"x":0,"y":0,"z":0},"ColleaguePositions":null},"RawJSON":""},"tutorials":[],"quests":{"21:1":{"QuestID":1,"PackID":21,"Values":[7]}},"cleared_quests":{"21:1":true}}`
+16
View File
@@ -271,6 +271,22 @@ func (s *Store) Position() (SavedPosition, bool) {
return s.position, s.position.PackID != 0
}
// LastPlayedPackID is the pack of the latest committed field position. The
// client suppresses SaveUserPosition in hidden packs, retaining the outside
// field position rather than choosing a temporary hidden scene on relogin.
// Zero means no position has been committed, so LoginUser can keep its seed.
func (s *Store) LastPlayedPackID() (uint64, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.position.PackID == 0 {
return 0, nil
}
if s.position.PackID < 0 || s.position.PackID > int(^uint32(0)>>1) || s.position.Position.MapID <= 0 || s.position.RawJSON == "" {
return 0, ErrInvalidPosition
}
return uint64(s.position.PackID), nil
}
func (s *Store) TutorialCleared(id int) bool {
s.mu.RLock()
defer s.mu.RUnlock()
+3
View File
@@ -345,6 +345,9 @@ func (s *Server) dispatch(path string, request []byte) (int, []byte, error) {
if err := s.progress.SaveUserPosition(request); err != nil {
return 0, nil, fmt.Errorf("%s: %w", path, err)
}
if saved, found := s.progress.Position(); found {
slog.Info("field position saved", "pack", saved.PackID, "map", saved.Position.MapID)
}
return 7, nil, nil
case "/TutorialClear":
if err := s.progress.ClearTutorial(request); err != nil {
+76
View File
@@ -0,0 +1,76 @@
package world
import (
"errors"
"bd2server/internal/server/gamedata"
)
// LastPlayedPackID prevents LoginUser from selecting a field that this server
// cannot restore. A fresh account keeps the versioned login seed's pack.
func (s *Service) LastPlayedPackID() (uint64, error) {
id, err := s.state.LastPlayedPackID()
if err != nil || id == 0 {
return id, err
}
if !s.packUnlocked(int(id)) {
return 0, errors.New("world: saved login pack is unavailable")
}
if pack, field := s.fieldPacks[int(id)]; field {
saved, _ := s.state.Position()
if !pack.MapIDs[saved.Position.MapID] {
return 0, errors.New("world: saved arena map does not belong to pack")
}
}
return id, nil
}
func (s *Service) AttachSquadLevel(provider func() (uint64, error)) error {
if provider == nil {
return errors.New("world: nil squad level provider")
}
s.squadLevel = provider
return nil
}
func (s *Service) fieldPackUnlocked(pack gamedata.FieldPack) bool {
// A valid committed field position is the existing account's entry marker.
// Restoration does not repeat a purchase or an unlock check.
if saved, found := s.state.Position(); found && saved.PackID == int(pack.ID) && pack.MapIDs[saved.Position.MapID] {
return true
}
// Scheduled arena availability needs its own persisted event domain.
if pack.UseSchedule != 0 {
return false
}
if pack.SquadLevel != 0 {
if s.squadLevel == nil {
return false
}
level, err := s.squadLevel()
if err != nil || level < pack.SquadLevel {
return false
}
}
if pack.HasOpenRule {
if s.inventory == nil {
return false
}
found := false
for _, item := range s.inventory.All() {
if item.Type == 19 && item.ID == pack.TicketID && item.Count != 0 {
found = true
break
}
}
if !found {
return false
}
}
// Paid packs require a durable purchase marker. An existing saved position
// proves this account already entered it; this does not implement purchase.
if pack.BuyPrice != 0 {
return false
}
return true
}
+121
View File
@@ -0,0 +1,121 @@
package world
import (
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/progress"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func TestArenaPositionRestoresAcrossRestartWithoutStoryQuestOrRepurchase(t *testing.T) {
storage := stateio.NewMemory()
state, err := progress.OpenStore(storage)
if err != nil {
t.Fatal(err)
}
const raw = `{"MapId":30011,"PlayerPosition":{"x":-2.1,"y":0,"z":6.4}}`
request := wire.AppendVarint(nil, 2, 3001)
request = wire.AppendString(request, 3, raw)
if err := state.SaveUserPosition(request); err != nil {
t.Fatal(err)
}
state, err = progress.OpenStore(storage)
if err != nil {
t.Fatal(err)
}
s := testService()
s.state = state
s.fieldPacks = map[int]gamedata.FieldPack{3001: {ID: 3001, Type: 3, BuyPrice: 99, UseSchedule: 1, HasOpenRule: true, TicketID: 13001, SquadLevel: 50, MapIDs: map[int]bool{30011: true}}}
if pack, err := s.LastPlayedPackID(); err != nil || pack != 3001 {
t.Fatalf("restored login pack=%d err=%v", pack, err)
}
request = wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 3001)
code, body, handled, err := s.Handle("/PackInGameInfo", request)
if err != nil || !handled || code != 5 {
t.Fatalf("arena info code=%d handled=%v err=%v", code, handled, err)
}
position, _, err := wire.Bytes(body, 4)
if err != nil || string(position) != raw {
t.Fatalf("restored arena position=%s err=%v", position, err)
}
if _, found, _ := wire.Bytes(body, 2); found {
t.Fatal("arena inherited a story quest")
}
if pack, err := s.CurrentPackID(); err != nil || pack != 3001 {
t.Fatalf("current arena pack=%d err=%v", pack, err)
}
request = wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 21)
_, body, _, err = s.Handle("/PackInGameInfo", request)
if err != nil {
t.Fatal(err)
}
position, _, _ = wire.Bytes(body, 4)
if string(position) != "{}" {
t.Fatal("arena position leaked into story pack")
}
}
func TestArenaEntryPreservesTicketPurchaseAndMapRestrictions(t *testing.T) {
s := testService()
s.fieldPacks = map[int]gamedata.FieldPack{9001: {ID: 9001, Type: 3, HasOpenRule: true, TicketID: 555, MapIDs: map[int]bool{90011: true}}}
inventory, err := player.OpenInventory(stateio.NewMemory(), &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
s.inventory = inventory
if s.packUnlocked(9001) {
t.Fatal("missing content ticket accepted")
}
if _, err := inventory.GrantOnce("arena-ticket", []gamedata.BattleReward{{Type: 19, ID: 555, Count: 1}}); err != nil {
t.Fatal(err)
}
if !s.packUnlocked(9001) {
t.Fatal("free arena with owned ticket refused")
}
pack := s.fieldPacks[9001]
pack.BuyPrice = 10
s.fieldPacks[9001] = pack
if s.packUnlocked(9001) {
t.Fatal("paid arena was purchased implicitly")
}
request := wire.AppendVarint(nil, 2, 9001)
request = wire.AppendString(request, 3, `{"MapId":211,"PlayerPosition":{"x":1}}`)
if err := s.state.SaveUserPosition(request); err != nil {
t.Fatal(err)
}
if _, err := s.LastPlayedPackID(); err == nil {
t.Fatal("foreign map restored as arena")
}
}
func TestLastPlayedStoryPackRestoresItsExactPosition(t *testing.T) {
s := testService()
s.packs = map[int]map[int]gamedata.QuestDesign{21: s.quests, 22: {1: {ID: 1}}}
s.transitions = map[int]gamedata.PackTransition{21: {PackID: 21, NextPackID: 22}}
for quest := range s.quests {
if err := s.state.ClearQuest(quest, 21); err != nil {
t.Fatal(err)
}
}
const raw = `{"MapId":221,"PlayerPosition":{"x":7.5,"y":0,"z":-2.2},"ColleaguePositions":null}`
request := wire.AppendVarint(nil, 2, 22)
request = wire.AppendString(request, 3, raw)
if err := s.state.SaveUserPosition(request); err != nil {
t.Fatal(err)
}
if pack, err := s.LastPlayedPackID(); err != nil || pack != 22 {
t.Fatalf("last story pack=%d err=%v", pack, err)
}
request = wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 22)
_, body, _, err := s.Handle("/PackInGameInfo", request)
if err != nil {
t.Fatal(err)
}
position, _, _ := wire.Bytes(body, 4)
if string(position) != raw {
t.Fatalf("story position=%s", position)
}
}
+24 -2
View File
@@ -63,17 +63,23 @@ func Load(seedPath, gameDataRoot, gameDataVersion string, storage stateio.Store,
return nil, err
}
quests := packs[seed.PackID]
fieldPacks, err := gamedata.LoadFieldPacks(gameDataRoot, gameDataVersion)
if err != nil {
return nil, err
}
if _, ok := quests[seed.StartQuestID]; !ok {
return nil, fmt.Errorf("world: start quest %d is absent from QuestTable%d", seed.StartQuestID, seed.PackID)
}
transition := transitions[seed.PackID]
activePack := seed.PackID
if saved, found := state.Position(); found {
if _, known := packs[saved.PackID]; known {
_, storyKnown := packs[saved.PackID]
_, fieldKnown := fieldPacks[saved.PackID]
if storyKnown || fieldKnown {
activePack = saved.PackID
}
}
service := &Service{seed: seed, state: state, starter: starter, equipment: equipment, inventory: inventory, wallet: wallet, characters: characters, quests: quests, transition: transition, packs: packs, transitions: transitions, activePack: activePack}
service := &Service{seed: seed, state: state, starter: starter, equipment: equipment, inventory: inventory, wallet: wallet, characters: characters, quests: quests, transition: transition, packs: packs, transitions: transitions, activePack: activePack, fieldPacks: fieldPacks}
return service, nil
}
@@ -107,6 +113,8 @@ func (s *Service) setCurrentPack(packID int) {
}
type Service struct {
fieldPacks map[int]gamedata.FieldPack
squadLevel func() (uint64, error)
seed Seed
state *progress.Store
starter *player.Starter
@@ -249,6 +257,9 @@ func requestQuest(request []byte) (int, int, error) {
}
func (s *Service) questsFor(packID int) (map[int]gamedata.QuestDesign, bool) {
if _, exists := s.fieldPacks[packID]; exists {
return map[int]gamedata.QuestDesign{}, true
}
if s.packs != nil {
quests, found := s.packs[packID]
return quests, found
@@ -273,6 +284,9 @@ func (s *Service) transitionFor(packID int) gamedata.PackTransition {
// pack to be complete. This accepts the configured next story pack only after
// its predecessor is complete, without exposing arbitrary GameData tables.
func (s *Service) packUnlocked(packID int) bool {
if pack, exists := s.fieldPacks[packID]; exists {
return s.fieldPackUnlocked(pack)
}
current := s.seed.PackID
for steps := 0; steps < 64 && current != 0; steps++ {
if current == packID {
@@ -425,9 +439,17 @@ func (s *Service) packInfoFor(packID int) ([]byte, error) {
out = wire.AppendBytes(out, 3, packed)
}
position := "{}"
mapID := 0
restored := false
if saved, found := s.state.Position(); found && saved.PackID == packID && saved.RawJSON != "" {
if pack, arena := s.fieldPacks[packID]; arena && !pack.MapIDs[saved.Position.MapID] {
return nil, fmt.Errorf("world: saved map %d does not belong to arena pack %d", saved.Position.MapID, packID)
}
position = saved.RawJSON
mapID = saved.Position.MapID
restored = true
}
slog.Info("world: deliver field position", "pack", packID, "map", mapID, "restored", restored)
out = wire.AppendString(out, 4, position)
// The remaining starter-only records were observed in the official
// starter-pack response. They represent reputation, hunting-ground, statue,