feat(all): add hunting and events, commerce and rewards, binary calendars, GameData caching and login diagnostics
Implement hunting AP and progress, event tasks, exchanges, games, stories and actions; connect battle and reward handling. Add CashShop plugin and installation, server purchases and entitlements, recharge rules and plugin version metadata. Distribute binary calendars independently; cache GameData in memory and fix single-connection query deadlocks. Improve automatic login and recovery diagnostics; document configuration and release flows.
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
package world
|
||||
|
||||
// CashPackagePackCleared verifies the complete main quest chain at the
|
||||
// requested difficulty using persisted server progress.
|
||||
func (s *Service) CashPackagePackCleared(packID, difficulty uint64) bool {
|
||||
if s.storyCatalog == nil || difficulty > 2 || packID == 0 {
|
||||
return false
|
||||
}
|
||||
pack, ok := s.storyCatalog.Packs[int(packID)]
|
||||
if !ok || len(pack.MainQuestIDs) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, quest := range pack.MainQuestIDs {
|
||||
if !s.state.QuestCleared(quest, int(packID), int(difficulty)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package world
|
||||
|
||||
import "fmt"
|
||||
|
||||
func (s *Service) AttachHuntingGround(provider interface{ EnsureForPack(int) ([]byte, error) }) error {
|
||||
if provider == nil {
|
||||
return fmt.Errorf("world: missing hunting ground provider")
|
||||
}
|
||||
s.huntingGround = provider
|
||||
return nil
|
||||
}
|
||||
|
||||
// HuntingEligibility follows the hunting tutorial: hard/extreme grounds need
|
||||
// completion of the same story difficulty, independently of the last hunt boss.
|
||||
func (s *Service) HuntingEligibility(pack int, difficulty uint64) error {
|
||||
if difficulty > 2 || !s.packUnlocked(pack) {
|
||||
return fmt.Errorf("world: hunting pack or difficulty locked")
|
||||
}
|
||||
if difficulty == 0 {
|
||||
return nil
|
||||
}
|
||||
if s.storyCatalog == nil {
|
||||
return fmt.Errorf("world: hunting story design unavailable")
|
||||
}
|
||||
d, ok := s.storyCatalog.Packs[pack]
|
||||
if !ok || len(d.MainQuestIDs) == 0 || !s.questDifficulties[pack][int(difficulty)] {
|
||||
return fmt.Errorf("world: hunting difficulty unavailable")
|
||||
}
|
||||
for _, id := range d.MainQuestIDs {
|
||||
if !s.state.QuestCleared(id, pack, int(difficulty)) {
|
||||
return fmt.Errorf("world: hunting requires completed story difficulty")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type fakeHuntingGround struct {
|
||||
calls []int
|
||||
row []byte
|
||||
}
|
||||
|
||||
func (h *fakeHuntingGround) EnsureForPack(pack int) ([]byte, error) {
|
||||
h.calls = append(h.calls, pack)
|
||||
return h.row, nil
|
||||
}
|
||||
|
||||
func TestPackLoadRestoresActualHuntingState(t *testing.T) {
|
||||
s := testService()
|
||||
h := &fakeHuntingGround{row: wire.AppendVarint(nil, 2, 91)}
|
||||
if err := s.AttachHuntingGround(h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := s.packInfoFor(s.seed.PackID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, ok, err := wire.Bytes(out, 12)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("ground missing: %v", err)
|
||||
}
|
||||
if id, _, _ := wire.Varint(b, 2); id != 91 {
|
||||
t.Fatalf("seed placeholder replaced state: %d", id)
|
||||
}
|
||||
if len(h.calls) != 1 || h.calls[0] != s.seed.PackID {
|
||||
t.Fatal("wrong loaded pack")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (s *Service) AttachResearchRuntime(root, version string, economy interface {
|
||||
Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
|
||||
}) error {
|
||||
if economy == nil {
|
||||
return fmt.Errorf("world: nil research economy")
|
||||
}
|
||||
chars, err := gamedata.LoadResearchCharacters(root, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.researchCharacters = chars
|
||||
s.researchEconomy = economy
|
||||
s.researchDesigns = map[int]gamedata.FieldResearchDesign{}
|
||||
s.researchLoader = func(pack int) (gamedata.FieldResearchDesign, error) {
|
||||
return gamedata.LoadFieldResearch(root, version, pack)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Service) researchDesign(pack int) (gamedata.FieldResearchDesign, error) {
|
||||
if d, ok := s.researchDesigns[pack]; ok {
|
||||
return d, nil
|
||||
}
|
||||
if s.researchLoader == nil {
|
||||
return gamedata.FieldResearchDesign{}, fmt.Errorf("world: research design unavailable")
|
||||
}
|
||||
d, err := s.researchLoader(pack)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
s.researchDesigns[pack] = d
|
||||
return d, nil
|
||||
}
|
||||
func intsRequest(raw []byte, number int) ([]uint64, error) {
|
||||
var out []uint64
|
||||
err := wire.Walk(raw, func(f wire.Field) error {
|
||||
if f.Number != number {
|
||||
return nil
|
||||
}
|
||||
if f.Type == 0 {
|
||||
v, n := binary.Uvarint(f.Value)
|
||||
if n <= 0 {
|
||||
return wire.ErrMalformed
|
||||
}
|
||||
out = append(out, v)
|
||||
return nil
|
||||
}
|
||||
if f.Type != 2 {
|
||||
return ErrInvalidRequest
|
||||
}
|
||||
for raw := f.Value; len(raw) > 0; {
|
||||
v, n := binary.Uvarint(raw)
|
||||
if n <= 0 {
|
||||
return wire.ErrMalformed
|
||||
}
|
||||
out = append(out, v)
|
||||
raw = raw[n:]
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *Service) handleFieldResearch(request []byte) (int, []byte, bool, error) {
|
||||
seq, present, err := wire.Varint(request, 1)
|
||||
if err != nil || !present || seq == 0 {
|
||||
return 59, nil, true, ErrInvalidRequest
|
||||
}
|
||||
pack, err := requestPack(request)
|
||||
if err != nil {
|
||||
return 59, nil, true, err
|
||||
}
|
||||
id, _, err := wire.Varint(request, 3)
|
||||
if err != nil || id == 0 || !s.packUnlocked(pack) || s.state.ActivePackID() != pack {
|
||||
return 59, nil, true, ErrInvalidRequest
|
||||
}
|
||||
design, err := s.researchDesign(pack)
|
||||
if err != nil {
|
||||
return 59, nil, true, err
|
||||
}
|
||||
obj, ok := design.Objects[int(id)]
|
||||
if !ok || obj.CollectionID == 0 && obj.Reward.Type == 0 {
|
||||
return 59, nil, true, ErrInvalidRequest
|
||||
}
|
||||
position, saved := s.state.Position()
|
||||
mapOK := false
|
||||
for _, mapID := range obj.Maps {
|
||||
if saved && position.PackID == pack && position.Position.MapID == mapID {
|
||||
mapOK = true
|
||||
}
|
||||
}
|
||||
if !mapOK {
|
||||
return 59, nil, true, fmt.Errorf("%w: research outside current map", ErrInvalidRequest)
|
||||
}
|
||||
// Quest interactions use QuestUpdate, while FieldObjectResearch grants the
|
||||
// object's collection/reward after its quest interaction is no longer active.
|
||||
for _, quest := range obj.InteractionQuests {
|
||||
if _, active := s.state.QuestInPack(quest, pack, s.questDifficulty(pack)); active && !s.state.QuestCleared(quest, pack, s.questDifficulty(pack)) {
|
||||
return 59, nil, true, fmt.Errorf("%w: research belongs to active quest", ErrInvalidRequest)
|
||||
}
|
||||
}
|
||||
if obj.Type == 1 {
|
||||
eligible := false
|
||||
// This server validates a learned research talent. Client animation and
|
||||
// temporary highlight flags are presentation state rather than authority.
|
||||
if s.characters != nil {
|
||||
for _, c := range s.characters.All() {
|
||||
if c.TalentLevel > 0 && s.researchCharacters[c.ID] {
|
||||
eligible = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !eligible {
|
||||
return 59, nil, true, fmt.Errorf("%w: research talent unavailable", ErrInvalidRequest)
|
||||
}
|
||||
}
|
||||
prior, err := s.state.ResearchObjects(pack)
|
||||
if err != nil {
|
||||
return 59, nil, true, err
|
||||
}
|
||||
for _, v := range prior {
|
||||
if v == int(id) {
|
||||
items, found, e := s.state.ResearchObjectReply(pack, int(id))
|
||||
if e != nil {
|
||||
return 59, nil, true, e
|
||||
}
|
||||
if !found {
|
||||
return 59, nil, true, fmt.Errorf("world: researched object reward receipt missing")
|
||||
}
|
||||
return 59, append(wire.AppendVarint(nil, 1, seq), items...), true, nil
|
||||
}
|
||||
}
|
||||
if s.researchEconomy == nil {
|
||||
return 59, nil, true, fmt.Errorf("world: research economy unavailable")
|
||||
}
|
||||
var rewards []gamedata.Reward
|
||||
if obj.Reward.Type != 0 && obj.Reward.Count > 0 {
|
||||
rewards = append(rewards, obj.Reward)
|
||||
}
|
||||
if obj.CollectionID > 0 {
|
||||
rewards = append(rewards, gamedata.Reward{Type: 17, ID: uint64(obj.CollectionID), Count: 1})
|
||||
}
|
||||
bundle, err := s.researchEconomy.Apply(fmt.Sprintf("research:%d:%d", pack, id), nil, rewards)
|
||||
if err != nil {
|
||||
return 59, nil, true, err
|
||||
}
|
||||
var items []byte
|
||||
err = wire.Walk(bundle, func(f wire.Field) error {
|
||||
if f.Number == 1 && f.Type == 2 {
|
||||
items = wire.AppendBytes(items, 2, f.Value)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 59, nil, true, err
|
||||
}
|
||||
if err = s.state.MarkResearchObject(pack, int(id), items); err != nil {
|
||||
return 59, nil, true, err
|
||||
}
|
||||
return 59, append(wire.AppendVarint(nil, 1, seq), items...), true, nil
|
||||
}
|
||||
func fieldCountType(obj gamedata.FieldRewardObject) uint64 {
|
||||
if obj.Type == 3 {
|
||||
return 2
|
||||
}
|
||||
if obj.Type == 6 {
|
||||
return 4
|
||||
}
|
||||
return 3
|
||||
}
|
||||
func (s *Service) handlePackRewardCounts(request []byte) (int, []byte, bool, error) {
|
||||
seq, present, err := wire.Varint(request, 1)
|
||||
if err != nil || !present || seq == 0 {
|
||||
return 226, nil, true, ErrInvalidRequest
|
||||
}
|
||||
packs, err := intsRequest(request, 2)
|
||||
if err != nil || len(packs) == 0 {
|
||||
return 226, nil, true, ErrInvalidRequest
|
||||
}
|
||||
types, err := intsRequest(request, 3)
|
||||
if err != nil {
|
||||
return 226, nil, true, err
|
||||
}
|
||||
if len(types) == 0 {
|
||||
types = []uint64{1, 2, 3, 4}
|
||||
}
|
||||
var out []byte
|
||||
seen := map[[2]uint64]bool{}
|
||||
for _, p := range packs {
|
||||
pack := int(p)
|
||||
if pack <= 0 || !s.packUnlocked(pack) {
|
||||
return 226, nil, true, ErrInvalidRequest
|
||||
}
|
||||
for _, t := range types {
|
||||
if t < 1 || t > 4 {
|
||||
return 226, nil, true, ErrInvalidRequest
|
||||
}
|
||||
key := [2]uint64{p, t}
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
var count, max uint64
|
||||
if t == 1 {
|
||||
d, e := s.researchDesign(pack)
|
||||
if e != nil {
|
||||
return 226, nil, true, e
|
||||
}
|
||||
ids, e := s.state.ResearchObjects(pack)
|
||||
if e != nil {
|
||||
return 226, nil, true, e
|
||||
}
|
||||
for _, o := range d.Objects {
|
||||
if o.CollectionID > 0 || o.Reward.Type > 0 {
|
||||
max++
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
if o, ok := d.Objects[id]; ok && (o.CollectionID > 0 || o.Reward.Type > 0) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
d, e := s.fieldObjectDesign(pack)
|
||||
if e != nil {
|
||||
return 226, nil, true, e
|
||||
}
|
||||
for _, o := range d.Objects {
|
||||
if fieldCountType(o) != t {
|
||||
continue
|
||||
}
|
||||
max++
|
||||
period, e := s.fieldObjectPeriod(o)
|
||||
if e != nil {
|
||||
continue
|
||||
}
|
||||
opened, e := s.state.FieldRewardOpened(pack, o.ID, period)
|
||||
if e != nil {
|
||||
return 226, nil, true, e
|
||||
}
|
||||
if opened {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
info := wire.AppendVarint(nil, 1, t)
|
||||
info = wire.AppendVarint(info, 2, p)
|
||||
info = wire.AppendVarint(info, 3, count)
|
||||
info = wire.AppendVarint(info, 4, max)
|
||||
out = wire.AppendBytes(out, 1, info)
|
||||
}
|
||||
}
|
||||
return 226, out, true, nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/progress"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type researchEconomySpy struct {
|
||||
calls int
|
||||
rewards []gamedata.Reward
|
||||
}
|
||||
|
||||
func (e *researchEconomySpy) Apply(_ string, _ []gamedata.Reward, rewards []gamedata.Reward) ([]byte, error) {
|
||||
e.calls++
|
||||
e.rewards = rewards
|
||||
item := wire.AppendVarint(nil, 1, 17)
|
||||
item = wire.AppendVarint(item, 2, 103)
|
||||
item = wire.AppendVarint(item, 3, 1)
|
||||
return wire.AppendBytes(nil, 1, item), nil
|
||||
}
|
||||
func TestResearchRequiresCurrentMapPersistsRewardAndCounts(t *testing.T) {
|
||||
s := testService()
|
||||
storage := stateio.NewMemory()
|
||||
var err error
|
||||
s.state, err = progress.OpenStore(storage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = s.state.SetActivePackID(21); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
save := wire.AppendVarint(nil, 1, 1)
|
||||
save = wire.AppendVarint(save, 2, 21)
|
||||
save = wire.AppendString(save, 3, `{"MapId":211,"PlayerPosition":{"x":0,"y":0,"z":0},"ColleaguePositions":null}`)
|
||||
if err = s.state.SaveUserPosition(save); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.researchDesigns = map[int]gamedata.FieldResearchDesign{21: {Objects: map[int]gamedata.FieldResearchObject{401: {ID: 401, Maps: []int{211}, CollectionID: 103}, 402: {ID: 402, Maps: []int{212}, CollectionID: 104}}}}
|
||||
spy := &researchEconomySpy{}
|
||||
s.researchEconomy = spy
|
||||
req := wire.AppendVarint(nil, 1, 10)
|
||||
req = wire.AppendVarint(req, 2, 21)
|
||||
bad := wire.AppendVarint(append([]byte(nil), req...), 3, 402)
|
||||
if _, _, _, err = s.Handle("/FieldObjectResearch", bad); err == nil {
|
||||
t.Fatal("accepted different map")
|
||||
}
|
||||
req = wire.AppendVarint(req, 3, 401)
|
||||
code, first, handled, err := s.Handle("/FieldObjectResearch", req)
|
||||
if err != nil || !handled || code != 59 {
|
||||
t.Fatalf("research: %d %v %v", code, handled, err)
|
||||
}
|
||||
if len(spy.rewards) != 1 || spy.rewards[0].Type != 17 || spy.rewards[0].ID != 103 {
|
||||
t.Fatalf("collection reward must use type17: %+v", spy.rewards)
|
||||
}
|
||||
s.state, err = progress.OpenStore(storage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, again, _, err := s.Handle("/FieldObjectResearch", req)
|
||||
if err != nil || !bytes.Equal(first, again) || spy.calls != 1 {
|
||||
t.Fatalf("replay duplicated reward or lost response: calls%d err%v", spy.calls, err)
|
||||
}
|
||||
counts := wire.AppendVarint(nil, 1, 11)
|
||||
counts = wire.AppendVarint(counts, 2, 21)
|
||||
counts = wire.AppendVarint(counts, 3, 1)
|
||||
code, body, _, err := s.Handle("/PackRewardObjectCount", counts)
|
||||
if err != nil || code != 226 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, _, err := wire.Bytes(body, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count, _, _ := wire.Varint(info, 3)
|
||||
max, _, _ := wire.Varint(info, 4)
|
||||
if count != 1 || max != 2 {
|
||||
t.Fatalf("count%d max%d", count, max)
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,7 @@ func (s *Service) setCurrentPack(packID int) {
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
huntingGround interface{ EnsureForPack(int) ([]byte, error) }
|
||||
battleActive func() bool
|
||||
questDifficulties map[int]map[int]bool
|
||||
startingPackID int
|
||||
@@ -183,22 +184,28 @@ type Service struct {
|
||||
fieldObjects map[int]gamedata.FieldObjectDesign
|
||||
fieldObjectLoader func(int) (gamedata.FieldObjectDesign, error)
|
||||
fieldReset gamedata.FieldResetSchedule
|
||||
squadLevel func() (uint64, error)
|
||||
seed Seed
|
||||
state *progress.Store
|
||||
starter *player.Starter
|
||||
equipment *player.EquipmentInventory
|
||||
inventory *player.Inventory
|
||||
wallet *player.Wallet
|
||||
characters *player.CharacterStore
|
||||
collection *player.CollectionStore
|
||||
decks *deck.Store
|
||||
quests map[int]gamedata.QuestDesign
|
||||
transition gamedata.PackTransition
|
||||
packs map[int]map[int]gamedata.QuestDesign
|
||||
transitions map[int]gamedata.PackTransition
|
||||
activePackMu sync.RWMutex
|
||||
activePack int
|
||||
researchDesigns map[int]gamedata.FieldResearchDesign
|
||||
researchLoader func(int) (gamedata.FieldResearchDesign, error)
|
||||
researchCharacters map[uint64]bool
|
||||
researchEconomy interface {
|
||||
Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
|
||||
}
|
||||
squadLevel func() (uint64, error)
|
||||
seed Seed
|
||||
state *progress.Store
|
||||
starter *player.Starter
|
||||
equipment *player.EquipmentInventory
|
||||
inventory *player.Inventory
|
||||
wallet *player.Wallet
|
||||
characters *player.CharacterStore
|
||||
collection *player.CollectionStore
|
||||
decks *deck.Store
|
||||
quests map[int]gamedata.QuestDesign
|
||||
transition gamedata.PackTransition
|
||||
packs map[int]map[int]gamedata.QuestDesign
|
||||
transitions map[int]gamedata.PackTransition
|
||||
activePackMu sync.RWMutex
|
||||
activePack int
|
||||
}
|
||||
|
||||
func (s *Service) AttachCollection(collection *player.CollectionStore) error {
|
||||
@@ -225,6 +232,10 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
||||
return s.handleFieldObjectInfo(request)
|
||||
case "/FieldObjectReward":
|
||||
return s.handleFieldObjectReward(request)
|
||||
case "/FieldObjectResearch":
|
||||
return s.handleFieldResearch(request)
|
||||
case "/PackRewardObjectCount":
|
||||
return s.handlePackRewardCounts(request)
|
||||
case "/QuestInfo", "/QuestAccept", "/QuestGiveUp":
|
||||
return s.handleQuestSelection(path, request)
|
||||
case "/PackBuy":
|
||||
@@ -562,6 +573,22 @@ func (s *Service) packInfo() ([]byte, error) {
|
||||
}
|
||||
|
||||
func (s *Service) packInfoFor(packID int) ([]byte, error) {
|
||||
out, err := s.basePackInfoFor(packID)
|
||||
if err != nil || s.huntingGround == nil {
|
||||
return out, err
|
||||
}
|
||||
ground, err := s.huntingGround.EnsureForPack(packID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ground) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
out, _, err = wire.ReplaceBytes(out, 12, ground)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Service) basePackInfoFor(packID int) ([]byte, error) {
|
||||
var out []byte
|
||||
if active := s.firstUnclearedQuestFor(packID); active != 0 {
|
||||
quest := s.questInfoWire(packID, active)
|
||||
|
||||
Reference in New Issue
Block a user