From 3b08040a0e68df21f17eda29cc5a2ff7ab7e14b8 Mon Sep 17 00:00:00 2001 From: Flechazo <2558755403@qq.com> Date: Mon, 5 Oct 2026 20:24:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(server):=20support=20dynamic=20hidden=20eve?= =?UTF-8?q?nt=20pack=20purchase=20and=20entry=20why=20the=20f^^kkkkkk=20do?= =?UTF-8?q?es=20sh^t=20AI=20love=20writing=20useless=20tests=20so=20much?= =?UTF-8?q?=20just=20to=20clutter=20up=20my=20repo,=20looks=20like=20I?= =?UTF-8?q?=E2=80=99ll=20have=20to=20find=20some=20time=20to=20clean=20thi?= =?UTF-8?q?s=20s^^t=20up=20later.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/cmd/bd2server/main.go | 6 + go/internal/server/eventplay/field_binding.go | 52 ++++++ go/internal/server/eventplay/field_pack.go | 155 ++++++++++++++++++ .../server/eventplay/field_pack_test.go | 96 +++++++++++ go/internal/server/eventplay/play.go | 1 + .../server/gamedata/event_field_pack.go | 88 ++++++++++ .../server/gamedata/event_field_pack_test.go | 30 ++++ go/internal/server/gamedata/event_play.go | 5 + go/internal/server/world/event_field_pack.go | 100 +++++++++++ go/internal/server/world/field_objects.go | 4 +- go/internal/server/world/pack_purchase.go | 36 +++- go/internal/server/world/world.go | 24 ++- go/seed/v2_35_10/event_field_bindings.json | 6 + 13 files changed, 592 insertions(+), 11 deletions(-) create mode 100644 go/internal/server/eventplay/field_binding.go create mode 100644 go/internal/server/eventplay/field_pack.go create mode 100644 go/internal/server/eventplay/field_pack_test.go create mode 100644 go/internal/server/gamedata/event_field_pack.go create mode 100644 go/internal/server/gamedata/event_field_pack_test.go create mode 100644 go/internal/server/world/event_field_pack.go create mode 100644 go/seed/v2_35_10/event_field_bindings.json diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index 046077c..0b30e8d 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -956,6 +956,12 @@ func serve(args []string) (serveErr error) { } eventPlayService.AttachBattleChallenges(eventBattleChallenges) eventPlayService.AttachHubCalendars(serverConfig) + if err := eventPlayService.AttachFieldBindingsFile(filepath.Join(filepath.Dir(*worldSeed), "event_field_bindings.json")); err != nil { + return fmt.Errorf("attach hidden field bindings: %w", err) + } + if err := worldService.AttachEventFieldPacks(eventPlayService); err != nil { + return fmt.Errorf("attach event field packs: %w", err) + } battleService.AttachEventBattle(eventPlayService) eventActionsDesign, err := gamedata.LoadEventActionsDesign(gameData, *gameDataVersion) if err != nil { diff --git a/go/internal/server/eventplay/field_binding.go b/go/internal/server/eventplay/field_binding.go new file mode 100644 index 0000000..52a1f23 --- /dev/null +++ b/go/internal/server/eventplay/field_binding.go @@ -0,0 +1,52 @@ +package eventplay + +import ( + "bd2server/internal/server/versionconfig" + "bytes" + "encoding/json" + "fmt" + "io" + "os" +) + +// FieldBinding records client constants that are not expressed as foreign +// keys in GameData. Concrete pack identities belong in the version seed. +type FieldBinding struct { + PackID int `json:"pack_id"` + EventType uint64 `json:"event_type"` + ContentOpenType uint64 `json:"content_open_type"` +} + +func (s *Service) AttachFieldBindingsFile(path string) error { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + var seed struct { + Version string `json:"version"` + Bindings []FieldBinding `json:"bindings"` + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err = decoder.Decode(&seed); err != nil { + return err + } + if err = decoder.Decode(new(any)); err != io.EOF { + return fmt.Errorf("eventplay: trailing field binding data") + } + if seed.Version != versionconfig.Current().GameVersion { + return fmt.Errorf("eventplay: field binding version mismatch") + } + s.mu.Lock() + defer s.mu.Unlock() + seen := map[int]bool{} + for _, b := range seed.Bindings { + pack, ok := s.design.FieldPacks[b.PackID] + if !ok || len(pack.MapIDs) == 0 || b.EventType == 0 || seen[b.PackID] { + return fmt.Errorf("eventplay: invalid field binding pack %d", b.PackID) + } + seen[b.PackID] = true + } + s.fieldBindings = append([]FieldBinding(nil), seed.Bindings...) + return nil +} diff --git a/go/internal/server/eventplay/field_pack.go b/go/internal/server/eventplay/field_pack.go new file mode 100644 index 0000000..f39d25a --- /dev/null +++ b/go/internal/server/eventplay/field_pack.go @@ -0,0 +1,155 @@ +package eventplay + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" + "fmt" + "sort" +) + +// ListEventFieldPacks authorizes hidden fields through installed pack rules +// and the live project calendar. A hub field and a minigame field are separate +// routes: some public hubs have no PackEventMiniGameTable row at all. +func (s *Service) ListEventFieldPacks() ([]gamedata.EventFieldPack, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.eventFieldPacks() +} + +func (s *Service) ResolveEventFieldPack(id int) (gamedata.EventFieldPack, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + packs, err := s.eventFieldPacks() + if err != nil { + return gamedata.EventFieldPack{}, false, err + } + for _, p := range packs { + if p.ID == id { + return p, true, nil + } + } + return gamedata.EventFieldPack{}, false, nil +} + +func (s *Service) eventFieldPacks() ([]gamedata.EventFieldPack, error) { + now := s.now().UnixMilli() + result := map[int]gamedata.EventFieldPack{} + // Bound minigames use the hub play window even when their global schedule + // has a longer archive window. Zero means a known, currently closed binding. + boundEnds := map[uint64]int64{} + add := func(id int, uid, game, hub, mapID, point uint64, end int64) error { + base, ok := s.design.FieldPacks[id] + if !ok { + return nil + } + if len(base.MapIDs) == 0 { + return fmt.Errorf("eventplay: hidden pack %d has no installed map", id) + } + if mapID != 0 { + found := false + for _, m := range base.MapIDs { + if uint64(m) == mapID { + found = true + } + } + if !found { + return fmt.Errorf("eventplay: pack %d calendar map %d mismatch", id, mapID) + } + } + if old, exists := result[id]; exists && old.End >= end { + return nil + } + base.MapIDs = append([]int(nil), base.MapIDs...) + base.BuyRewards = append([]gamedata.Reward(nil), base.BuyRewards...) + base.ScheduleUID, base.GameID, base.HubID, base.End = uid, game, hub, end + base.InitialMapID, base.PointPositionID = mapID, point + if base.InitialMapID == 0 { + base.InitialMapID = uint64(base.MapIDs[0]) + } + result[id] = base + return nil + } + if s.hubCalendars != nil { + _, body, handled, err := s.hubCalendars.Handle("/EventHubInfo", wire.AppendVarint(nil, 1, 1)) + if err != nil { + return nil, err + } + if handled { + err = wire.Walk(body, func(f wire.Field) error { + if f.Number != 1 || f.Type != 2 { + return nil + } + start, end := int64(num(f.Value, 3)), int64(num(f.Value, 4)) + if err := wire.Walk(f.Value, func(setting wire.Field) error { + if setting.Number != 6 || setting.Type != 2 || num(setting.Value, 2) != 6 { + return nil + } + uids, err := list(setting.Value, 3) + if err != nil { + return err + } + for _, uid := range uids { + if _, exists := boundEnds[uid]; !exists { + boundEnds[uid] = 0 + } + if now >= start && now < end && boundEnds[uid] < end { + boundEnds[uid] = end + } + } + return nil + }); err != nil { + return err + } + if now < start || now >= end { + return nil + } + hubID := num(f.Value, 2) + hub, e := s.design.Row("PackEventHubTable", 14, hubID) + if e != nil { + return e + } + return add(int(num(hub, 20)), num(f.Value, 1), 0, hubID, 0, 0, end) + }) + if err != nil { + return nil, err + } + } + } + for _, c := range s.registry.List() { + for _, binding := range s.fieldBindings { + if c.Type == binding.EventType && now >= c.Start && now < c.End { + if err := add(binding.PackID, c.UID, c.ID, 0, 0, 0, c.End); err != nil { + return nil, err + } + p := result[binding.PackID] + p.ContentOpenType = binding.ContentOpenType + result[binding.PackID] = p + } + } + if c.Type != 11 || now < c.Start || now >= c.End { + continue + } + end := c.End + if hubEnd, bound := boundEnds[c.UID]; bound { + if hubEnd == 0 { + continue + } + if hubEnd < end { + end = hubEnd + } + } + game, err := s.design.Row("PackEventMiniGameTable", 8, c.ID) + if err != nil { + return nil, err + } + if err = add(int(num(game, 12)), c.UID, c.ID, 0, num(game, 9), num(game, 13), end); err != nil { + return nil, err + } + } + var packs []gamedata.EventFieldPack + for _, p := range result { + packs = append(packs, p) + } + sort.Slice(packs, func(i, j int) bool { return packs[i].ID < packs[j].ID }) + return packs, nil +} diff --git a/go/internal/server/eventplay/field_pack_test.go b/go/internal/server/eventplay/field_pack_test.go new file mode 100644 index 0000000..a30db6a --- /dev/null +++ b/go/internal/server/eventplay/field_pack_test.go @@ -0,0 +1,96 @@ +package eventplay + +import ( + "bd2server/internal/server/events" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/readonly" + "bd2server/internal/server/wire" + "os" + "testing" + "time" +) + +func TestClientConstantFieldBindingRequiresActiveMatchingEvent(t *testing.T) { + s, _ := makeService(t) + s.design.FieldPacks = map[int]gamedata.EventFieldPack{734: {ID: 734, MapIDs: []int{735}, InitialPosition: "{}"}} + s.fieldBindings = []FieldBinding{{PackID: 734, EventType: 20, ContentOpenType: 20}} + reg := events.NewRegistry() + if err := reg.Replace([]events.Schedule{{UID: 32, Type: 20, ID: 81, Start: 100, End: 200}}); err != nil { + t.Fatal(err) + } + s.registry = reg + p, ok, err := s.ResolveEventFieldPack(734) + if err != nil || !ok || p.ScheduleUID != 32 || p.ContentOpenType != 20 { + t.Fatalf("%+v %v %v", p, ok, err) + } + s.now = func() time.Time { return time.UnixMilli(200) } + if _, ok, err := s.ResolveEventFieldPack(734); err != nil || ok { + t.Fatalf("expired tactics %v %v", ok, err) + } +} + +func TestInstalledTacticsFieldBinding(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + reg := events.NewRegistry() + if err := reg.Replace([]events.Schedule{{UID: 81, Type: 20, ID: 8, Start: 1, End: 9999999999999}}); err != nil { + t.Fatal(err) + } + // Use the real catalog but an in-memory account and controlled clock. + d, err := gamedata.LoadEventPlayCatalog(root, "20260923193640") + if err != nil { + t.Fatal(err) + } + svc, _ := makeService(t) + svc.design = d + svc.registry = reg + if err = svc.AttachFieldBindingsFile("../../../seed/v2_35_10/event_field_bindings.json"); err != nil { + t.Fatal(err) + } + p, ok, err := svc.ResolveEventFieldPack(20000) + if err != nil || !ok || p.ContentOpenType != 20 || len(p.MapIDs) != 1 || p.MapIDs[0] != 200001 || p.BuyPrice != 0 { + t.Fatalf("%+v %v %v", p, ok, err) + } +} + +func TestHiddenHubPackUsesCalendarPlayWindowAndInstalledMaps(t *testing.T) { + s, _ := makeService(t) + s.registry = events.NewRegistry() + s.design.FieldPacks = map[int]gamedata.EventFieldPack{912: gamedata.EventFieldPack{ID: 912, MapIDs: []int{991, 992}, InitialPosition: "{}", BuyType: 4, BuyPrice: 20}} + s.design.Tables["PackEventHubTable"] = [][]byte{wire.AppendVarint(wire.AppendVarint(nil, 14, 55), 20, 912)} + scalar := func(n int, v uint64) readonly.Field { return readonly.Field{Number: n, Varint: v} } + s.AttachHubCalendars(&readonly.Seed{Responses: map[string]readonly.Response{"/EventHubInfo": {PacketCode: 222, Fields: []readonly.Field{{Number: 1, Type: 2, Fields: []readonly.Field{scalar(1, 77), scalar(2, 55), scalar(3, 100), scalar(4, 200), scalar(5, 300)}}}}}}) + p, ok, err := s.ResolveEventFieldPack(912) + if err != nil || !ok || p.ScheduleUID != 77 || p.HubID != 55 || p.End != 200 || p.BuyPrice != 20 || p.InitialMapID != 991 || p.InitialPosition != "{}" { + t.Fatalf("pack=%+v ok=%v err=%v", p, ok, err) + } + p.MapIDs[0] = 0 + again, _, _ := s.ResolveEventFieldPack(912) + if again.MapIDs[0] != 991 { + t.Fatal("caller changed design maps") + } + s.now = func() time.Time { return time.UnixMilli(200) } + if _, ok, err := s.ResolveEventFieldPack(912); err != nil || ok { + t.Fatalf("post-play purchase ok=%v err=%v", ok, err) + } +} + +func TestHiddenMiniGamePackRejectsForeignMapAndInactiveCalendar(t *testing.T) { + s, _ := makeService(t) + s.design.FieldPacks = map[int]gamedata.EventFieldPack{812: {ID: 812, MapIDs: []int{811}}} + s.design.Tables["PackEventMiniGameTable"] = [][]byte{row(map[int]uint64{8: 1, 12: 812, 9: 810, 13: 101})} + if _, _, err := s.ResolveEventFieldPack(812); err == nil { + t.Fatal("foreign map accepted") + } + s.design.Tables["PackEventMiniGameTable"] = [][]byte{row(map[int]uint64{8: 1, 12: 812, 9: 811, 13: 101})} + p, ok, err := s.ResolveEventFieldPack(812) + if err != nil || !ok || p.GameID != 1 || p.PointPositionID != 101 { + t.Fatalf("%+v %v %v", p, ok, err) + } + s.now = func() time.Time { return time.UnixMilli(9999999) } + if _, ok, err := s.ResolveEventFieldPack(812); err != nil || ok { + t.Fatalf("expired game available %v %v", ok, err) + } +} diff --git a/go/internal/server/eventplay/play.go b/go/internal/server/eventplay/play.go index 917a7bf..1ab2de4 100644 --- a/go/internal/server/eventplay/play.go +++ b/go/internal/server/eventplay/play.go @@ -59,6 +59,7 @@ type Service struct { rooms RoomRuntime onProgress func(uint64, uint64, uint64) error hubCalendars *readonly.Seed + fieldBindings []FieldBinding } func Open(store stateio.Store, root, version string, registry events.Resolver, economy Rewards) (*Service, error) { diff --git a/go/internal/server/gamedata/event_field_pack.go b/go/internal/server/gamedata/event_field_pack.go new file mode 100644 index 0000000..ef5d3c5 --- /dev/null +++ b/go/internal/server/gamedata/event_field_pack.go @@ -0,0 +1,88 @@ +package gamedata + +import ( + "database/sql" + "fmt" +) + +// EventFieldPack combines installed hidden-pack rules with a current calendar +// identity. Calendar times use milliseconds, as do event schedule protocols. +type EventFieldPack struct { + ID int + ScheduleUID, GameID, HubID uint64 + MapIDs []int + InitialPosition string + InitialMapID, PointPositionID uint64 + BuyType, BuyPrice uint64 + ContentOpenType uint64 + BuyRewards []Reward + End int64 +} + +func loadEventFieldPacks(db *sql.DB) (map[int]EventFieldPack, error) { + packs := map[int]EventFieldPack{} + 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 + } + kind, e := optionalScalar(raw, 55) + if e != nil { + rows.Close() + return nil, e + } + if kind != 100 { + continue + } + protoID, e := optionalScalar(raw, 25) + if e != nil || protoID != uint64(id) || id <= 0 { + rows.Close() + return nil, fmt.Errorf("gamedata: invalid hidden pack %d", id) + } + p := EventFieldPack{ID: id, InitialPosition: "{}"} + if p.BuyType, err = optionalScalar(raw, 11); err != nil { + rows.Close() + return nil, err + } + if p.BuyPrice, err = optionalScalar(raw, 7); err != nil { + rows.Close() + return nil, err + } + rewards, e := eventGameRewards(raw, 10, 9, 8) + if e != nil { + rows.Close() + return nil, e + } + for _, r := range rewards { + p.BuyRewards = append(p.BuyRewards, Reward{Type: r.Type, ID: r.ID, Count: r.Count}) + } + packs[id] = p + } + err = rows.Err() + rows.Close() + if err != nil { + return nil, err + } + rows, err = db.Query("SELECT id,packId FROM MapTable ORDER BY id") + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var id, packID int + if err = rows.Scan(&id, &packID); err != nil { + return nil, err + } + if p, ok := packs[packID]; ok { + p.MapIDs = append(p.MapIDs, id) + packs[packID] = p + } + } + return packs, rows.Err() +} diff --git a/go/internal/server/gamedata/event_field_pack_test.go b/go/internal/server/gamedata/event_field_pack_test.go new file mode 100644 index 0000000..42855c5 --- /dev/null +++ b/go/internal/server/gamedata/event_field_pack_test.go @@ -0,0 +1,30 @@ +package gamedata + +import ( + "os" + "reflect" + "testing" +) + +func TestInstalledHiddenTrackerPackRules(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + catalog, err := LoadEventPlayCatalog(root, "20260923193640") + if err != nil { + t.Fatal(err) + } + // Current GameData PackTable 12010: hidden type 100, no price/reward + // fields. MapTable binds all three maps, independently of minigame rows. + pack, ok := catalog.FieldPacks[12010] + if !ok || pack.BuyPrice != 0 || pack.BuyType != 0 || len(pack.BuyRewards) != 0 || !reflect.DeepEqual(pack.MapIDs, []int{1201001, 1201002, 1201003}) || pack.InitialPosition != "{}" { + t.Fatalf("tracker pack %+v exists %v", pack, ok) + } + for _, game := range catalog.Tables["PackEventMiniGameTable"] { + id, _ := optionalScalar(game, 12) + if id == 12010 { + t.Fatal("tracker unexpectedly became a PackEventMiniGameTable pack; recheck authoritative mapping") + } + } +} diff --git a/go/internal/server/gamedata/event_play.go b/go/internal/server/gamedata/event_play.go index 5f75b0c..4cb9cee 100644 --- a/go/internal/server/gamedata/event_play.go +++ b/go/internal/server/gamedata/event_play.go @@ -8,6 +8,7 @@ import ( // EventPlayCatalog keeps the installed static rows for event gameplay. type EventPlayCatalog struct { Tables map[string][][]byte + FieldPacks map[int]EventFieldPack root, version string } @@ -18,6 +19,10 @@ func LoadEventPlayCatalog(root, version string) (*EventPlayCatalog, error) { } defer done() c := &EventPlayCatalog{Tables: map[string][][]byte{}, root: root, version: version} + c.FieldPacks, e = loadEventFieldPacks(db) + if e != nil { + return nil, e + } names := []string{"PackEventHubTable", "PackEventListTable", "PackEventStoryGroupTable", "PackEventStoryTable", "PackEventBattleGroupTable", "PackEventBattleTable", "PackEventMiniGameTable", "FieldMiniGameRewardTable", "FieldMiniGameSpeedTable", "RhythmGameMusicTable", "RhythmGameGradeTable", "SichuanStageTable", "SichuanEventTable", "SichuanRewardTable", "HopscotchStageTable", "HopscotchRewardTable", "HopscotchDefaultTable", "FieldMiniGameSurvivalTable", "ActionGameDefaultTable", "ActionGameMissionTable", "ActionGameStageTable", "FieldMiniGameCharTable", "FieldMiniGameMapTable", "FieldMiniGameUpgradeTable", "FieldMiniGameUpgradeGroupTable", "FieldMiniGameSkillGroupTable", "FieldMiniGameSkillTable", "FieldMiniGameCharLevelTable", "FieldMiniGameMonsterTable", "FieldMiniGameSurvivalItemTable", "FieldMiniGameSurvivalBoxTable", "MGDRewardTable", "MGDWaveTable", "MGDDefaultTable"} for _, name := range names { rows, e := db.Query("SELECT ProtoBuf FROM " + name) diff --git a/go/internal/server/world/event_field_pack.go b/go/internal/server/world/event_field_pack.go new file mode 100644 index 0000000..c241444 --- /dev/null +++ b/go/internal/server/world/event_field_pack.go @@ -0,0 +1,100 @@ +package world + +import ( + "fmt" + "slices" + + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" +) + +// EventFieldPackSource binds installed hidden packs to the server's current +// calendars. A purchase receipt alone never authorizes an expired event. +type EventFieldPackSource interface { + ResolveEventFieldPack(int) (gamedata.EventFieldPack, bool, error) + ListEventFieldPacks() ([]gamedata.EventFieldPack, error) +} + +func (s *Service) AttachEventFieldPacks(source EventFieldPackSource) error { + if source == nil { + return fmt.Errorf("world: nil event field pack source") + } + s.eventFieldPacks = source + return nil +} + +func (s *Service) resolveEventFieldPack(id int) (gamedata.EventFieldPack, bool, error) { + if s.eventFieldPacks == nil { + return gamedata.EventFieldPack{}, false, nil + } + return s.eventFieldPacks.ResolveEventFieldPack(id) +} + +func (s *Service) eventPackPurchased(id int) bool { + if s.collection == nil { + return false + } + _, found := s.collection.Grant(packPurchaseIdentity(id)) + return found +} + +func (s *Service) eventPackDBInfo(pack gamedata.EventFieldPack) []byte { + row := wire.AppendVarint(nil, 1, uint64(pack.ID)) + if s.eventPackPurchased(pack.ID) { + row = wire.AppendVarint(row, 8, 1) + } + return row +} + +func (s *Service) eventPackInfoRows() ([][]byte, error) { + if s.eventFieldPacks == nil { + return nil, nil + } + packs, err := s.eventFieldPacks.ListEventFieldPacks() + if err != nil { + return nil, err + } + var rows [][]byte + for _, pack := range packs { + // PackManager buys a hidden pack only when PackInfo has no row for it. + if s.eventPackPurchased(pack.ID) { + rows = append(rows, s.eventPackDBInfo(pack)) + } + } + return rows, nil +} + +func (s *Service) enterEventFieldPack(pack gamedata.EventFieldPack) (int, []byte, bool, error) { + if !s.eventPackPurchased(pack.ID) { + return 0, nil, true, fmt.Errorf("%w: event pack %d is not purchased", ErrInvalidRequest, pack.ID) + } + position := pack.InitialPosition + if position == "" { + return 0, nil, true, fmt.Errorf("world: missing event pack initial position") + } + if saved, found := s.state.Position(); found && saved.PackID == pack.ID { + if !slices.Contains(pack.MapIDs, saved.Position.MapID) { + return 0, nil, true, fmt.Errorf("%w: map outside event pack", ErrInvalidRequest) + } + position = saved.RawJSON + } + response := wire.AppendString(nil, 4, position) + // The common callback dereferences HuntingGroundInfo even in hidden packs. + // Use the domain-generated empty/current snapshot; never borrow the outside + // map's monsters or story progress. + var hunting []byte + if s.huntingGround != nil { + var err error + hunting, err = s.huntingGround.EnsureForPack(pack.ID) + if err != nil { + return 0, nil, true, err + } + } + response = wire.AppendBytes(response, 12, hunting) + // Hidden-pack entry must retain the outside field position: the client + // intentionally suppresses SaveUserPosition while playing these packs. The + // persistent active pack also stays outside, so relogin cannot be stranded + // in a hidden scene after its calendar closes. + s.setCurrentPack(pack.ID) + return 5, response, true, nil +} diff --git a/go/internal/server/world/field_objects.go b/go/internal/server/world/field_objects.go index 788e0d6..9a6720d 100644 --- a/go/internal/server/world/field_objects.go +++ b/go/internal/server/world/field_objects.go @@ -33,7 +33,9 @@ func (s *Service) fieldObjectDesign(pack int) (gamedata.FieldObjectDesign, error } if _, story := s.packs[pack]; !story { if _, field := s.fieldPacks[pack]; !field { - return gamedata.FieldObjectDesign{}, fmt.Errorf("%w: unknown field pack", ErrInvalidRequest) + if _, event, err := s.resolveEventFieldPack(pack); err != nil || !event { + return gamedata.FieldObjectDesign{}, fmt.Errorf("%w: unknown field pack", ErrInvalidRequest) + } } } design, err := s.fieldObjectLoader(pack) diff --git a/go/internal/server/world/pack_purchase.go b/go/internal/server/world/pack_purchase.go index 132c7d6..19dbf4d 100644 --- a/go/internal/server/world/pack_purchase.go +++ b/go/internal/server/world/pack_purchase.go @@ -22,6 +22,16 @@ func (s *Service) handlePackBuy(request []byte) (int, []byte, bool, error) { if err != nil { return 0, nil, true, err } + if pack, found, err := s.resolveEventFieldPack(id); err != nil { + return 0, nil, true, err + } else if found { + bundle, err := s.purchasePack(id, pack.BuyType, pack.BuyPrice, pack.BuyRewards, false) + if err != nil { + return 0, nil, true, err + } + response := wire.AppendBytes(nil, 1, s.eventPackDBInfo(pack)) + return 6, wire.AppendBytes(response, 2, bundle), true, nil + } if !s.packUnlocked(id) { return 0, nil, true, fmt.Errorf("%w: unavailable purchase pack %d", ErrInvalidRequest, id) } @@ -101,14 +111,22 @@ func (s *Service) grantPurchaseRewards(identity string, rewards []gamedata.Rewar } func (s *Service) purchaseStoryPack(id int, initial bool) ([]byte, error) { - if s.storyCatalog == nil || s.collection == nil || s.wallet == nil || s.inventory == nil { + if s.storyCatalog == nil { return nil, fmt.Errorf("world: purchase services unavailable") } pack, exists := s.storyCatalog.Packs[id] if !exists { return nil, fmt.Errorf("%w: unknown purchase pack %d", ErrInvalidRequest, id) } - buyRewards := pack.BuyRewards + return s.purchasePack(id, pack.BuyType, pack.BuyPrice, pack.BuyRewards, initial) +} + +// purchasePack shares the same durable receipt and enclosing account transaction +// across story and calendar-authorized hidden event packs. +func (s *Service) purchasePack(id int, buyType, buyPrice uint64, buyRewards []gamedata.Reward, initial bool) ([]byte, error) { + if s.collection == nil || s.wallet == nil || s.inventory == nil { + return nil, fmt.Errorf("world: purchase services unavailable") + } identity := packPurchaseIdentity(id) if _, owned := s.collection.Grant(identity); owned { return []byte{}, nil @@ -118,19 +136,19 @@ func (s *Service) purchaseStoryPack(id int, initial bool) ([]byte, error) { return nil, fmt.Errorf("world: unsupported pack purchase reward type %d", reward.Type) } } - if !initial && pack.BuyPrice != 0 { + if !initial && buyPrice != 0 { var err error - switch pack.BuyType { + switch buyType { case 2: - _, err = s.wallet.SpendJewelryOnce(identity+":price", pack.BuyPrice) + _, err = s.wallet.SpendJewelryOnce(identity+":price", buyPrice) case 3: - _, err = s.wallet.SpendFreeJewelryOnce(identity+":price", pack.BuyPrice) + _, err = s.wallet.SpendFreeJewelryOnce(identity+":price", buyPrice) case 4: - _, err = s.wallet.SpendGoldOnce(identity+":price", pack.BuyPrice) + _, err = s.wallet.SpendGoldOnce(identity+":price", buyPrice) case 12: - _, err = s.wallet.SpendCatalystOnce(identity+":price", pack.BuyPrice) + _, err = s.wallet.SpendCatalystOnce(identity+":price", buyPrice) default: - return nil, fmt.Errorf("%w: unsupported pack purchase currency%d", ErrInvalidRequest, pack.BuyType) + return nil, fmt.Errorf("%w: unsupported pack purchase currency%d", ErrInvalidRequest, buyType) } if err != nil { return nil, err diff --git a/go/internal/server/world/world.go b/go/internal/server/world/world.go index e80f8cb..75a576b 100644 --- a/go/internal/server/world/world.go +++ b/go/internal/server/world/world.go @@ -181,6 +181,7 @@ type Service struct { packJamDesign *gamedata.PackJamDesign packJamMu sync.Mutex fieldPacks map[int]gamedata.FieldPack + eventFieldPacks EventFieldPackSource fieldObjects map[int]gamedata.FieldObjectDesign fieldObjectLoader func(int) (gamedata.FieldObjectDesign, error) fieldReset gamedata.FieldResetSchedule @@ -262,7 +263,15 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) if err != nil || !found || seq == 0 { return 0, nil, true, errors.New("world: PackInfo missing sequence") } - return 4, s.accountPackInfo(), true, nil + response := s.accountPackInfo() + rows, err := s.eventPackInfoRows() + if err != nil { + return 0, nil, true, err + } + for _, row := range rows { + response = wire.AppendBytes(response, 1, row) + } + return 4, response, true, nil case "/CharInfo": seq, found, err := wire.Varint(request, 1) if err != nil || !found || seq == 0 { @@ -344,6 +353,11 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) if err != nil { return 0, nil, true, err } + if eventPack, found, err := s.resolveEventFieldPack(pack); err != nil { + return 0, nil, true, err + } else if found { + return s.enterEventFieldPack(eventPack) + } if !s.packUnlocked(pack) { return 0, nil, true, fmt.Errorf("%w: unsupported pack %d", ErrInvalidRequest, pack) } @@ -441,6 +455,9 @@ func requestQuest(request []byte) (int, int, error) { } func (s *Service) questsFor(packID int) (map[int]gamedata.QuestDesign, bool) { + if _, found, err := s.resolveEventFieldPack(packID); err == nil && found { + return map[int]gamedata.QuestDesign{}, true + } if _, exists := s.fieldPacks[packID]; exists { return map[int]gamedata.QuestDesign{}, true } @@ -453,6 +470,11 @@ func (s *Service) questsFor(packID int) (map[int]gamedata.QuestDesign, bool) { // packUnlocked uses installed ContentOpen rules and real account tickets. func (s *Service) packUnlocked(packID int) bool { + if pack, found, err := s.resolveEventFieldPack(packID); err != nil { + return false + } else if found { + return s.eventPackPurchased(pack.ID) + } if pack, exists := s.fieldPacks[packID]; exists { return s.fieldPackUnlocked(pack) } diff --git a/go/seed/v2_35_10/event_field_bindings.json b/go/seed/v2_35_10/event_field_bindings.json new file mode 100644 index 0000000..82ac6b2 --- /dev/null +++ b/go/seed/v2_35_10/event_field_bindings.json @@ -0,0 +1,6 @@ +{ + "version": "2.35.10", + "bindings": [ + {"pack_id": 20000, "event_type": 20, "content_open_type": 20} + ] +}