feat(server): persist recruitment and serve pack reward summaries
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (s *Service) handlePackSummary(request []byte) (int, []byte, bool, error) {
|
||||
seq, found, err := wire.Varint(request, 1)
|
||||
if err != nil || !found || seq == 0 || seq > 0x7fffffff {
|
||||
return 0, nil, true, fmt.Errorf("%w: PackSummaryInfoList missing or invalid sequence", ErrInvalidRequest)
|
||||
}
|
||||
var response []byte
|
||||
for _, row := range s.packDBInfoRows() {
|
||||
id, found, err := wire.Varint(row, 1)
|
||||
if err != nil || !found {
|
||||
return 0, nil, true, fmt.Errorf("world: invalid account pack row")
|
||||
}
|
||||
if !s.packSummaryTargets[int(id)] {
|
||||
continue
|
||||
}
|
||||
// These are collected reward counts, not quest completion or remaining
|
||||
// rewards. Field object and research grants are not implemented. The
|
||||
// client's monster category requires UseBattleSkip && Type != 3 &&
|
||||
// RegenId > 0; the current story battles (pack21 monsters 1..4 and
|
||||
// pack22 monsters 1..8) have both RegenId and UseBattleSkip zero and
|
||||
// their persisted battle reward grants do not count in this category.
|
||||
// Regenerating field reward battles are not implemented. The collected
|
||||
// counts are therefore zero (protobuf defaults). The client loads the
|
||||
// maxima from PackRewardSummaryData and calculates the remainder.
|
||||
response = wire.AppendBytes(response, 1, wire.AppendVarint(nil, 1, id))
|
||||
}
|
||||
return 625, response, true, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestPackSummaryUsesUnlockedPackAndCollectedCounts(t *testing.T) {
|
||||
s := testService()
|
||||
s.packSummaryTargets = map[int]bool{21: true, 22: true}
|
||||
s.packs = map[int]map[int]gamedata.QuestDesign{21: s.quests, 22: {1: {ID: 1}}}
|
||||
s.transitions = map[int]gamedata.PackTransition{21: {PackID: 21, NextPackID: 22}}
|
||||
request := wire.AppendVarint(nil, 1, 7)
|
||||
code, got, handled, err := s.Handle("/PackSummaryInfoList", request)
|
||||
want := wire.AppendBytes(nil, 1, wire.AppendVarint(nil, 1, 21))
|
||||
if err != nil || !handled || code != 625 || !bytes.Equal(got, want) {
|
||||
t.Fatalf("initial summary code=%d handled=%v body=%x err=%v", code, handled, got, err)
|
||||
}
|
||||
if err := s.state.ClearQuest(1, 21); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, got, _, err = s.Handle("/PackSummaryInfoList", request)
|
||||
if err != nil || !bytes.Equal(got, want) {
|
||||
t.Fatalf("quest completion changed collected rewards: %x %v", got, err)
|
||||
}
|
||||
for _, id := range []int{2, 3} {
|
||||
if err := s.state.ClearQuest(id, 21); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, got, _, err = s.Handle("/PackSummaryInfoList", request)
|
||||
want = wire.AppendBytes(want, 1, wire.AppendVarint(nil, 1, 22))
|
||||
if err != nil || !bytes.Equal(got, want) {
|
||||
t.Fatalf("unlocked summary=%x want=%x err=%v", got, want, err)
|
||||
}
|
||||
// A non-target pack must never enter the reward summary even if it is
|
||||
// represented in the account's PackDBInfo rows.
|
||||
delete(s.packSummaryTargets, 21)
|
||||
_, got, _, err = s.Handle("/PackSummaryInfoList", request)
|
||||
want = wire.AppendBytes(nil, 1, wire.AppendVarint(nil, 1, 22))
|
||||
if err != nil || !bytes.Equal(got, want) {
|
||||
t.Fatalf("filtered summary=%x want=%x err=%v", got, want, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackSummaryRejectsInvalidSequence(t *testing.T) {
|
||||
s := testService()
|
||||
for _, request := range [][]byte{nil, {8}, wire.AppendVarint(nil, 1, 0), wire.AppendVarint(nil, 1, 0x80000000)} {
|
||||
_, _, handled, err := s.Handle("/PackSummaryInfoList", request)
|
||||
if !handled || !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatalf("request=%x handled=%v err=%v", request, handled, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (s *Service) ResolveRecruitNPC(npcID uint64, root, version string, design *gamedata.RecruitDesign) (uint64, error) {
|
||||
pack, err := s.CurrentPackID()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
npc, err := gamedata.LoadRecruitNPC(root, version, pack, npcID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.resolveRecruitNPC(npc, design)
|
||||
}
|
||||
|
||||
func (s *Service) resolveRecruitNPC(npc gamedata.RecruitNPC, design *gamedata.RecruitDesign) (uint64, error) {
|
||||
if design == nil || s.state == nil {
|
||||
return 0, fmt.Errorf("world: recruitment unavailable")
|
||||
}
|
||||
pack, err := s.CurrentPackID()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
position, found := s.state.Position()
|
||||
if !found || position.PackID != pack || position.Position.MapID <= 0 || uint64(position.Position.MapID) != npc.MapID {
|
||||
return 0, fmt.Errorf("world: recruit NPC outside saved current map")
|
||||
}
|
||||
rule, found := design.Rules[npc.ScoutID]
|
||||
if !found || rule.Type != 0 || rule.PackID == 0 || !s.packCompleteFor(int(rule.PackID)) {
|
||||
return 0, fmt.Errorf("world: ordinary recruitment requires completed story pack")
|
||||
}
|
||||
// QuestPacket treats main/sub quest NPC use separately. The local server
|
||||
// has one story difficulty and tracks active quest progress by pack; reject
|
||||
// a referenced active quest using the client's == / <= range branches.
|
||||
for i, quest := range npc.QuestRanges {
|
||||
kind, known := npc.QuestTypes[quest]
|
||||
if quest == 0 || !known || kind > 1 {
|
||||
continue
|
||||
}
|
||||
if kind == 0 && s.packCompleteFor(pack) {
|
||||
continue
|
||||
}
|
||||
quests, knownPack := s.questsFor(pack)
|
||||
if !knownPack {
|
||||
return 0, fmt.Errorf("world: recruit NPC quest pack unavailable")
|
||||
}
|
||||
for id := range quests {
|
||||
activeKind, known := npc.QuestTypes[uint64(id)]
|
||||
if !known || activeKind != kind {
|
||||
continue
|
||||
}
|
||||
if s.state.QuestCleared(id, pack) {
|
||||
continue
|
||||
}
|
||||
active := false
|
||||
if kind == 0 {
|
||||
active = s.canClear(pack, id)
|
||||
} else {
|
||||
_, active = s.state.QuestInPack(id, pack)
|
||||
}
|
||||
if !active {
|
||||
continue
|
||||
}
|
||||
match := uint64(id) == quest
|
||||
if npc.QuestEnableTypes[i] == 1 || npc.QuestEnableTypes[i] == 3 {
|
||||
match = quest <= uint64(id)
|
||||
}
|
||||
if match {
|
||||
return 0, fmt.Errorf("world: recruit NPC is used by active quest")
|
||||
}
|
||||
}
|
||||
}
|
||||
return npc.ScoutID, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func recruitNPCPosition(t *testing.T, s *Service, pack, mapID int) {
|
||||
t.Helper()
|
||||
request := wire.AppendVarint(nil, 1, 1)
|
||||
request = wire.AppendVarint(request, 2, uint64(pack))
|
||||
request = wire.AppendString(request, 3, `{"MapId":`+strconv.Itoa(mapID)+`,"PlayerPosition":{"x":0,"y":0,"z":0},"ColleaguePositions":[]}`)
|
||||
if err := s.state.SaveUserPosition(request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRecruitNPCRequiresMapStoryCompletionAndOrdinaryType(t *testing.T) {
|
||||
s := testService()
|
||||
d := &gamedata.RecruitDesign{Rules: map[uint64]gamedata.RecruitRule{40: {ID: 40, Type: 0, PackID: 21}}}
|
||||
npc := gamedata.RecruitNPC{ID: 7, MapID: 9, ScoutID: 40}
|
||||
if _, err := s.resolveRecruitNPC(npc, d); err == nil {
|
||||
t.Fatal("accepted NPC without saved map")
|
||||
}
|
||||
recruitNPCPosition(t, s, 21, 9)
|
||||
if _, err := s.resolveRecruitNPC(npc, d); err == nil {
|
||||
t.Fatal("accepted incomplete story")
|
||||
}
|
||||
for _, id := range []int{1, 2, 3} {
|
||||
if err := s.state.ClearQuest(id, 21); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if id, err := s.resolveRecruitNPC(npc, d); err != nil || id != 40 {
|
||||
t.Fatalf("resolved=%d err=%v", id, err)
|
||||
}
|
||||
recruitNPCPosition(t, s, 21, 8)
|
||||
if _, err := s.resolveRecruitNPC(npc, d); err == nil {
|
||||
t.Fatal("accepted NPC in other map")
|
||||
}
|
||||
recruitNPCPosition(t, s, 21, 9)
|
||||
d.Rules[40] = gamedata.RecruitRule{ID: 40, Type: 1, PackID: 21}
|
||||
if _, err := s.resolveRecruitNPC(npc, d); err == nil {
|
||||
t.Fatal("accepted special NPC through ordinary endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRecruitNPCBlocksActiveQuestRangesAfterEarlierRecruitChapter(t *testing.T) {
|
||||
s := testService()
|
||||
s.activePack = 22
|
||||
s.packs = map[int]map[int]gamedata.QuestDesign{21: s.quests, 22: {1: {ID: 1}, 2: {ID: 2}}}
|
||||
s.transitions = map[int]gamedata.PackTransition{21: {PackID: 21, NextPackID: 22}}
|
||||
for _, id := range []int{1, 2, 3} {
|
||||
if err := s.state.ClearQuest(id, 21); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
recruitNPCPosition(t, s, 22, 9)
|
||||
d := &gamedata.RecruitDesign{Rules: map[uint64]gamedata.RecruitRule{40: {ID: 40, Type: 0, PackID: 21}}}
|
||||
npc := gamedata.RecruitNPC{ID: 7, MapID: 9, ScoutID: 40, QuestEnableTypes: []uint64{0}, QuestRanges: []uint64{1}, QuestTypes: map[uint64]uint64{1: 0, 2: 0}}
|
||||
if _, err := s.resolveRecruitNPC(npc, d); err == nil {
|
||||
t.Fatal("accepted NPC used by active main quest")
|
||||
}
|
||||
npc.QuestRanges[0] = 2
|
||||
if id, err := s.resolveRecruitNPC(npc, d); err != nil || id != 40 {
|
||||
t.Fatalf("future quest blocked NPC=%d err=%v", id, err)
|
||||
}
|
||||
npc.QuestTypes[1] = 1
|
||||
npc.QuestRanges[0] = 1
|
||||
request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1), 3, 22)
|
||||
if _, err := s.state.UpdateQuest(request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.resolveRecruitNPC(npc, d); err == nil {
|
||||
t.Fatal("accepted NPC used by active subquest")
|
||||
}
|
||||
if err := s.state.ClearQuest(1, 22); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id, err := s.resolveRecruitNPC(npc, d); err != nil || id != 40 {
|
||||
t.Fatalf("cleared subquest still blocked NPC=%d err=%v", id, err)
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,10 @@ func Load(seedPath, gameDataRoot, gameDataVersion string, storage stateio.Store,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
packSummaryTargets, err := gamedata.LoadPackSummaryTargets(gameDataRoot, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := quests[seed.StartQuestID]; !ok {
|
||||
return nil, fmt.Errorf("world: start quest %d is absent from QuestTable%d", seed.StartQuestID, seed.PackID)
|
||||
}
|
||||
@@ -80,6 +84,7 @@ func Load(seedPath, gameDataRoot, gameDataVersion string, storage stateio.Store,
|
||||
}
|
||||
}
|
||||
service := &Service{seed: seed, state: state, starter: starter, equipment: equipment, inventory: inventory, wallet: wallet, characters: characters, quests: quests, transition: transition, packs: packs, transitions: transitions, activePack: activePack, fieldPacks: fieldPacks}
|
||||
service.packSummaryTargets = packSummaryTargets
|
||||
return service, nil
|
||||
}
|
||||
|
||||
@@ -113,23 +118,24 @@ func (s *Service) setCurrentPack(packID int) {
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
fieldPacks map[int]gamedata.FieldPack
|
||||
squadLevel func() (uint64, error)
|
||||
seed Seed
|
||||
state *progress.Store
|
||||
starter *player.Starter
|
||||
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
|
||||
packSummaryTargets map[int]bool
|
||||
fieldPacks map[int]gamedata.FieldPack
|
||||
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 {
|
||||
@@ -150,6 +156,8 @@ func (s *Service) AttachDecks(decks *deck.Store) error {
|
||||
|
||||
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
switch path {
|
||||
case "/PackSummaryInfoList":
|
||||
return s.handlePackSummary(request)
|
||||
case "/PackInfo":
|
||||
seq, found, err := wire.Varint(request, 1)
|
||||
if err != nil || !found || seq == 0 {
|
||||
|
||||
Reference in New Issue
Block a user