feat(server): persist recruitment and serve pack reward summaries

This commit is contained in:
2026-10-04 13:53:24 +08:00
parent e8bb0cba14
commit 31d605eaca
15 changed files with 1447 additions and 32 deletions
+34
View File
@@ -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)
}
}
}
+78
View File
@@ -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)
}
}
+25 -17
View File
@@ -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 {