diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index 880d0cf..aaf2b75 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -445,6 +445,9 @@ func serve(args []string) (serveErr error) { if err != nil { return fmt.Errorf("load world state: %w", err) } + if err := login.AttachLastPlayedPack(worldService); err != nil { + return fmt.Errorf("attach persisted login destination: %w", err) + } // Restore all earned seed ownership before validating persisted upgrades. // A quest costume is not a collection entry; attaching it after opening // collection would reject its otherwise valid burst ledger on restart. diff --git a/go/internal/server/account/last_pack_test.go b/go/internal/server/account/last_pack_test.go new file mode 100644 index 0000000..fe6ab6b --- /dev/null +++ b/go/internal/server/account/last_pack_test.go @@ -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") + } +} diff --git a/go/internal/server/account/login.go b/go/internal/server/account/login.go index 5df502c..cc1d242 100644 --- a/go/internal/server/account/login.go +++ b/go/internal/server/account/login.go @@ -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 diff --git a/go/internal/server/gamedata/field_pack.go b/go/internal/server/gamedata/field_pack.go new file mode 100644 index 0000000..e0e39ec --- /dev/null +++ b/go/internal/server/gamedata/field_pack.go @@ -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 +} diff --git a/go/internal/server/gamedata/field_pack_test.go b/go/internal/server/gamedata/field_pack_test.go new file mode 100644 index 0000000..bd19a20 --- /dev/null +++ b/go/internal/server/gamedata/field_pack_test.go @@ -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") + } +} diff --git a/go/internal/server/progress/persistence_test.go b/go/internal/server/progress/persistence_test.go index 6252d1c..1f80316 100644 --- a/go/internal/server/progress/persistence_test.go +++ b/go/internal/server/progress/persistence_test.go @@ -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}}` diff --git a/go/internal/server/progress/progress.go b/go/internal/server/progress/progress.go index 772d545..ec3464d 100644 --- a/go/internal/server/progress/progress.go +++ b/go/internal/server/progress/progress.go @@ -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() diff --git a/go/internal/server/session/server.go b/go/internal/server/session/server.go index ad82b6a..f9bc5d5 100644 --- a/go/internal/server/session/server.go +++ b/go/internal/server/session/server.go @@ -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 { diff --git a/go/internal/server/world/field_pack.go b/go/internal/server/world/field_pack.go new file mode 100644 index 0000000..c7e86e2 --- /dev/null +++ b/go/internal/server/world/field_pack.go @@ -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 +} diff --git a/go/internal/server/world/field_pack_test.go b/go/internal/server/world/field_pack_test.go new file mode 100644 index 0000000..d1b6e77 --- /dev/null +++ b/go/internal/server/world/field_pack_test.go @@ -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) + } +} diff --git a/go/internal/server/world/world.go b/go/internal/server/world/world.go index 2e2b39d..4f0caf8 100644 --- a/go/internal/server/world/world.go +++ b/go/internal/server/world/world.go @@ -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,