fix(server): support dynamic hidden event pack purchase and entry
This commit is contained in:
@@ -956,6 +956,12 @@ func serve(args []string) (serveErr error) {
|
|||||||
}
|
}
|
||||||
eventPlayService.AttachBattleChallenges(eventBattleChallenges)
|
eventPlayService.AttachBattleChallenges(eventBattleChallenges)
|
||||||
eventPlayService.AttachHubCalendars(serverConfig)
|
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)
|
battleService.AttachEventBattle(eventPlayService)
|
||||||
eventActionsDesign, err := gamedata.LoadEventActionsDesign(gameData, *gameDataVersion)
|
eventActionsDesign, err := gamedata.LoadEventActionsDesign(gameData, *gameDataVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ type Service struct {
|
|||||||
rooms RoomRuntime
|
rooms RoomRuntime
|
||||||
onProgress func(uint64, uint64, uint64) error
|
onProgress func(uint64, uint64, uint64) error
|
||||||
hubCalendars *readonly.Seed
|
hubCalendars *readonly.Seed
|
||||||
|
fieldBindings []FieldBinding
|
||||||
}
|
}
|
||||||
|
|
||||||
func Open(store stateio.Store, root, version string, registry events.Resolver, economy Rewards) (*Service, error) {
|
func Open(store stateio.Store, root, version string, registry events.Resolver, economy Rewards) (*Service, error) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
// EventPlayCatalog keeps the installed static rows for event gameplay.
|
// EventPlayCatalog keeps the installed static rows for event gameplay.
|
||||||
type EventPlayCatalog struct {
|
type EventPlayCatalog struct {
|
||||||
Tables map[string][][]byte
|
Tables map[string][][]byte
|
||||||
|
FieldPacks map[int]EventFieldPack
|
||||||
root, version string
|
root, version string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +19,10 @@ func LoadEventPlayCatalog(root, version string) (*EventPlayCatalog, error) {
|
|||||||
}
|
}
|
||||||
defer done()
|
defer done()
|
||||||
c := &EventPlayCatalog{Tables: map[string][][]byte{}, root: root, version: version}
|
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"}
|
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 {
|
for _, name := range names {
|
||||||
rows, e := db.Query("SELECT ProtoBuf FROM " + name)
|
rows, e := db.Query("SELECT ProtoBuf FROM " + name)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -33,9 +33,11 @@ func (s *Service) fieldObjectDesign(pack int) (gamedata.FieldObjectDesign, error
|
|||||||
}
|
}
|
||||||
if _, story := s.packs[pack]; !story {
|
if _, story := s.packs[pack]; !story {
|
||||||
if _, field := s.fieldPacks[pack]; !field {
|
if _, field := s.fieldPacks[pack]; !field {
|
||||||
|
if _, event, err := s.resolveEventFieldPack(pack); err != nil || !event {
|
||||||
return gamedata.FieldObjectDesign{}, fmt.Errorf("%w: unknown field pack", ErrInvalidRequest)
|
return gamedata.FieldObjectDesign{}, fmt.Errorf("%w: unknown field pack", ErrInvalidRequest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
design, err := s.fieldObjectLoader(pack)
|
design, err := s.fieldObjectLoader(pack)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return design, err
|
return design, err
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ func (s *Service) handlePackBuy(request []byte) (int, []byte, bool, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, nil, true, err
|
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) {
|
if !s.packUnlocked(id) {
|
||||||
return 0, nil, true, fmt.Errorf("%w: unavailable purchase pack %d", ErrInvalidRequest, 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) {
|
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")
|
return nil, fmt.Errorf("world: purchase services unavailable")
|
||||||
}
|
}
|
||||||
pack, exists := s.storyCatalog.Packs[id]
|
pack, exists := s.storyCatalog.Packs[id]
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, fmt.Errorf("%w: unknown purchase pack %d", ErrInvalidRequest, id)
|
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)
|
identity := packPurchaseIdentity(id)
|
||||||
if _, owned := s.collection.Grant(identity); owned {
|
if _, owned := s.collection.Grant(identity); owned {
|
||||||
return []byte{}, nil
|
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)
|
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
|
var err error
|
||||||
switch pack.BuyType {
|
switch buyType {
|
||||||
case 2:
|
case 2:
|
||||||
_, err = s.wallet.SpendJewelryOnce(identity+":price", pack.BuyPrice)
|
_, err = s.wallet.SpendJewelryOnce(identity+":price", buyPrice)
|
||||||
case 3:
|
case 3:
|
||||||
_, err = s.wallet.SpendFreeJewelryOnce(identity+":price", pack.BuyPrice)
|
_, err = s.wallet.SpendFreeJewelryOnce(identity+":price", buyPrice)
|
||||||
case 4:
|
case 4:
|
||||||
_, err = s.wallet.SpendGoldOnce(identity+":price", pack.BuyPrice)
|
_, err = s.wallet.SpendGoldOnce(identity+":price", buyPrice)
|
||||||
case 12:
|
case 12:
|
||||||
_, err = s.wallet.SpendCatalystOnce(identity+":price", pack.BuyPrice)
|
_, err = s.wallet.SpendCatalystOnce(identity+":price", buyPrice)
|
||||||
default:
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ type Service struct {
|
|||||||
packJamDesign *gamedata.PackJamDesign
|
packJamDesign *gamedata.PackJamDesign
|
||||||
packJamMu sync.Mutex
|
packJamMu sync.Mutex
|
||||||
fieldPacks map[int]gamedata.FieldPack
|
fieldPacks map[int]gamedata.FieldPack
|
||||||
|
eventFieldPacks EventFieldPackSource
|
||||||
fieldObjects map[int]gamedata.FieldObjectDesign
|
fieldObjects map[int]gamedata.FieldObjectDesign
|
||||||
fieldObjectLoader func(int) (gamedata.FieldObjectDesign, error)
|
fieldObjectLoader func(int) (gamedata.FieldObjectDesign, error)
|
||||||
fieldReset gamedata.FieldResetSchedule
|
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 {
|
if err != nil || !found || seq == 0 {
|
||||||
return 0, nil, true, errors.New("world: PackInfo missing sequence")
|
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":
|
case "/CharInfo":
|
||||||
seq, found, err := wire.Varint(request, 1)
|
seq, found, err := wire.Varint(request, 1)
|
||||||
if err != nil || !found || seq == 0 {
|
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 {
|
if err != nil {
|
||||||
return 0, nil, true, err
|
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) {
|
if !s.packUnlocked(pack) {
|
||||||
return 0, nil, true, fmt.Errorf("%w: unsupported pack %d", ErrInvalidRequest, 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) {
|
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 {
|
if _, exists := s.fieldPacks[packID]; exists {
|
||||||
return map[int]gamedata.QuestDesign{}, true
|
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.
|
// packUnlocked uses installed ContentOpen rules and real account tickets.
|
||||||
func (s *Service) packUnlocked(packID int) bool {
|
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 {
|
if pack, exists := s.fieldPacks[packID]; exists {
|
||||||
return s.fieldPackUnlocked(pack)
|
return s.fieldPackUnlocked(pack)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"version": "2.35.10",
|
||||||
|
"bindings": [
|
||||||
|
{"pack_id": 20000, "event_type": 20, "content_open_type": 20}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user