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:
2026-10-05 11:50:31 +08:00
parent eb445f88f4
commit d8771caef2
222 changed files with 19000 additions and 4284 deletions
+19
View File
@@ -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
}
+35
View File
@@ -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
}
+39
View File
@@ -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)
}
}
+43 -16
View File
@@ -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)