feat: persist recruitment and serve pack reward summaries

This commit is contained in:
2026-10-04 13:53:24 +08:00
parent 68794c52c8
commit f4bb6cc0d5
15 changed files with 1447 additions and 32 deletions
+12
View File
@@ -581,6 +581,17 @@ func serve(args []string) (serveErr error) {
}
battleService := battle.NewService(gameData, *gameDataVersion, ownedItems, worldService.CurrentPackID)
characters := worldService.CharacterService()
recruitDesign, err := gamedata.LoadRecruitDesign(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load recruitment GameData: %w", err)
}
recruitService, err := player.NewRecruitService(&recruitDesign, &recruitDesign, collection, ownedItems, wallet,
func(npcID uint64) (uint64, error) {
return worldService.ResolveRecruitNPC(npcID, gameData, *gameDataVersion, &recruitDesign)
})
if err != nil {
return fmt.Errorf("load recruitment service: %w", err)
}
battleService.AttachCommittedHealth(func(health map[uint64]uint64) error {
for index, hp := range health {
maximum, err := characters.MaxHealth(index)
@@ -632,6 +643,7 @@ func serve(args []string) (serveErr error) {
costumeBurstService,
friendshipService,
masterTitleService,
recruitService,
foodService,
starter,
mailService,
@@ -0,0 +1,47 @@
package gamedata
import (
"database/sql"
"fmt"
)
// LoadPackSummaryTargets follows PackMapRewardInfo.IsSummaryTargetPackType in
// the 2.35.10 client. Pack identities and types come from the installed tables.
func LoadPackSummaryTargets(root, version string) (map[int]bool, error) {
db, closeDB, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer closeDB()
return loadPackSummaryTargets(db)
}
func loadPackSummaryTargets(db *sql.DB) (map[int]bool, error) {
rows, err := db.Query("SELECT id,ProtoBuf FROM PackTable ORDER BY id")
if err != nil {
return nil, err
}
defer rows.Close()
result := map[int]bool{}
for rows.Next() {
var id int
var raw []byte
if err := rows.Scan(&id, &raw); err != nil {
return nil, err
}
types, err := packedInts(raw, 55)
if err != nil || len(types) > 1 || id <= 0 {
return nil, fmt.Errorf("gamedata: invalid summary pack %d", id)
}
var packType uint64
if len(types) == 1 {
packType = types[0]
}
switch packType {
case 2, 3, 5, 7, 8, 9, 10, 12, 13, 14, 100:
continue
}
result[id] = true
}
return result, rows.Err()
}
@@ -0,0 +1,43 @@
package gamedata
import (
"bd2server/internal/server/wire"
"database/sql"
"testing"
)
func TestPackSummaryTargetsMatchClientCategories(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec("CREATE TABLE PackTable(id INTEGER,ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
// Client targets: story, character, evil castle, event, square, master story.
targetTypes := map[int]bool{0: true, 1: true, 4: true, 6: true, 11: true, 1000: true}
for _, typ := range []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 100, 1000} {
if _, err := db.Exec("INSERT INTO PackTable VALUES(?,?)", typ+1, wire.AppendVarint(nil, 55, uint64(typ))); err != nil {
t.Fatal(err)
}
}
targets, err := loadPackSummaryTargets(db)
if err != nil {
t.Fatal(err)
}
if len(targets) != len(targetTypes) {
t.Fatalf("unexpected targets %v", targets)
}
for typ := range targetTypes {
if !targets[typ+1] {
t.Fatalf("missing client target type %d", typ)
}
}
if _, err := db.Exec("INSERT INTO PackTable VALUES(2000,?)", wire.AppendVarint(wire.AppendVarint(nil, 55, 1), 55, 2)); err != nil {
t.Fatal(err)
}
if _, err := loadPackSummaryTargets(db); err == nil {
t.Fatal("duplicate scalar pack type accepted")
}
}
+115
View File
@@ -0,0 +1,115 @@
package gamedata
import (
"database/sql"
"fmt"
"math"
"time"
)
// RecruitRule is MercenaryScoutTable. Type zero uses MercenaryScout; type
// one belongs to the separate special recruitment protocol.
type RecruitRule struct {
ID, CostumeID, PackID, Type, TalkGroupID, AppearProb uint64
Costs []PromotionCost
}
type RecruitDesign struct {
Rules map[uint64]RecruitRule
Characters map[uint64]CharacterDesign
AppearCount, AutoResetMinute, ResetCount, ResetType, ResetLimit uint64
}
func (d *RecruitDesign) Character(id uint64) (CharacterDesign, bool) {
v, ok := d.Characters[id]
return v, ok
}
func LoadRecruitDesign(root, version string) (RecruitDesign, error) {
db, closeDB, err := openStatDatabase(root, version)
if err != nil {
return RecruitDesign{}, err
}
defer closeDB()
return loadRecruitDesign(db)
}
func loadRecruitDesign(db *sql.DB) (RecruitDesign, error) {
d := RecruitDesign{Rules: map[uint64]RecruitRule{}}
if db == nil {
return d, fmt.Errorf("gamedata: nil recruit database")
}
err := friendshipRows(db, "SELECT id,ProtoBuf FROM MercenaryScoutTable ORDER BY id", func(id uint64, p []byte) error {
r := RecruitRule{}
for i, dst := range []*uint64{&r.ID, &r.CostumeID, &r.PackID, &r.Type, &r.TalkGroupID, &r.AppearProb} {
v, e := friendshipScalar(p, []int{3, 2, 4, 9, 8, 1}[i])
if e != nil {
return e
}
*dst = v
}
counts, e := packedInts(p, 5)
if e != nil {
return e
}
ids, e := packedInts(p, 6)
if e != nil {
return e
}
types, e := packedInts(p, 7)
if e != nil {
return e
}
if r.ID != id || id == 0 || id > math.MaxInt32 || r.CostumeID == 0 || r.CostumeID > math.MaxInt32 || r.PackID > math.MaxInt32 || r.AppearProb > math.MaxInt32 || r.TalkGroupID > math.MaxInt32 || r.Type > 1 || len(counts) == 0 || len(counts) != len(ids) || len(ids) != len(types) {
return fmt.Errorf("invalid recruit rule %d", id)
}
for i, count := range counts {
if count == 0 || count > math.MaxInt32 || types[i] != 8 || ids[i] == 0 || ids[i] > math.MaxInt32 {
return fmt.Errorf("unsupported recruit cost %d", id)
}
r.Costs = append(r.Costs, PromotionCost{Type: types[i], ID: ids[i], Count: count})
}
d.Rules[id] = r
return nil
})
if err != nil {
return d, fmt.Errorf("gamedata: recruitment: %w", err)
}
d.Characters = map[uint64]CharacterDesign{}
for _, r := range d.Rules {
c, e := loadGachaCharacterDesign(db, r.CostumeID)
if e != nil {
return d, e
}
d.Characters[r.CostumeID] = c
}
var defaults []byte
if err := db.QueryRow("SELECT ProtoBuf FROM SpecialScoutInfoTable WHERE id=0").Scan(&defaults); err != nil {
return d, err
}
if err := decodeRecruitSpecialDefaults(defaults, &d); err != nil {
return d, err
}
if len(d.Rules) == 0 {
return d, fmt.Errorf("gamedata: empty recruitment table")
}
return d, nil
}
func decodeRecruitSpecialDefaults(defaults []byte, d *RecruitDesign) error {
resetID, err := friendshipScalar(defaults, 5)
if err != nil {
return err
}
if resetID != 0 {
return fmt.Errorf("gamedata: special recruitment currency has item ID")
}
for i, dst := range []*uint64{&d.AppearCount, &d.AutoResetMinute, &d.ResetCount, &d.ResetType, &d.ResetLimit} {
v, e := friendshipScalar(defaults, []int{1, 2, 4, 6, 7}[i])
if e != nil {
return e
}
*dst = v
}
if d.AppearCount == 0 || d.AppearCount > math.MaxInt32 || d.AutoResetMinute == 0 || d.AutoResetMinute > uint64(math.MaxInt64/int64(time.Minute)) || d.ResetType != 3 || d.ResetCount == 0 || d.ResetCount > math.MaxInt32 || d.ResetLimit == 0 || d.ResetLimit > math.MaxInt32 {
return fmt.Errorf("gamedata: invalid special recruitment defaults")
}
return nil
}
+134
View File
@@ -0,0 +1,134 @@
package gamedata
import (
"database/sql"
"fmt"
"math"
"os"
"path/filepath"
)
type RecruitNPC struct {
ID, MapID, ScoutID uint64
QuestEnableTypes, QuestRanges []uint64
QuestTypes map[uint64]uint64
}
// LoadRecruitNPC resolves the Scout interaction in the selected pack database;
// FieldNpcTable.PackId is often zero and is not the database's pack identity.
func LoadRecruitNPC(root, version string, packID int, npcID uint64) (RecruitNPC, error) {
if packID <= 0 || npcID == 0 || npcID > math.MaxInt32 {
return RecruitNPC{}, fmt.Errorf("gamedata: invalid recruit NPC")
}
plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID))
if err != nil {
return RecruitNPC{}, err
}
dir, err := os.MkdirTemp("", "bd2-recruit-npc-")
if err != nil {
return RecruitNPC{}, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "pack.db")
if err = os.WriteFile(path, plain, 0o600); err != nil {
return RecruitNPC{}, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return RecruitNPC{}, err
}
defer db.Close()
npc, err := loadRecruitNPC(db, npcID)
if err != nil {
return RecruitNPC{}, err
}
if len(npc.QuestRanges) == 0 {
return npc, nil
}
common, closeDB, err := openStatDatabase(root, version)
if err != nil {
return RecruitNPC{}, err
}
defer closeDB()
if err = loadRecruitNPCQuestTypes(common, packID, &npc); err != nil {
return RecruitNPC{}, err
}
return npc, nil
}
func loadRecruitNPC(db *sql.DB, npcID uint64) (RecruitNPC, error) {
var raw []byte
if err := db.QueryRow("SELECT ProtoBuf FROM FieldNpcTable WHERE id=?", npcID).Scan(&raw); err != nil {
return RecruitNPC{}, fmt.Errorf("gamedata: recruit NPC %d: %w", npcID, err)
}
npc := RecruitNPC{QuestTypes: map[uint64]uint64{}}
for field, dst := range map[int]*uint64{8: &npc.ID, 14: &npc.MapID} {
values, err := packedInts(raw, field)
if err != nil || len(values) != 1 || values[0] == 0 || values[0] > math.MaxInt32 {
return RecruitNPC{}, fmt.Errorf("gamedata: invalid recruit NPC %d field%d", npcID, field)
}
*dst = values[0]
}
interactions, err := packedInts(raw, 9)
if err != nil {
return RecruitNPC{}, err
}
values, err := packedInts(raw, 12)
if err != nil {
return RecruitNPC{}, err
}
if npc.ID != npcID || len(interactions) != len(values) {
return RecruitNPC{}, fmt.Errorf("gamedata: invalid recruit NPC interactions %d", npcID)
}
for i, interaction := range interactions {
if interaction == 4 {
npc.ScoutID = values[i]
break
}
}
if npc.ScoutID == 0 || npc.ScoutID > math.MaxInt32 {
return RecruitNPC{}, fmt.Errorf("gamedata: NPC %d has no Scout interaction", npcID)
}
npc.QuestEnableTypes, err = packedInts(raw, 18)
if err != nil {
return RecruitNPC{}, err
}
npc.QuestRanges, err = packedInts(raw, 19)
if err != nil {
return RecruitNPC{}, err
}
if len(npc.QuestEnableTypes) != len(npc.QuestRanges) {
return RecruitNPC{}, fmt.Errorf("gamedata: invalid recruit NPC quest ranges %d", npcID)
}
for i, quest := range npc.QuestRanges {
if npc.QuestEnableTypes[i] > 3 || quest > math.MaxInt32 {
return RecruitNPC{}, fmt.Errorf("gamedata: invalid recruit NPC quest gate %d", npcID)
}
}
return npc, nil
}
func loadRecruitNPCQuestTypes(db *sql.DB, packID int, npc *RecruitNPC) error {
rows, err := db.Query(fmt.Sprintf("SELECT id,ProtoBuf FROM QuestTable%d", packID))
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var quest uint64
var raw []byte
if err := rows.Scan(&quest, &raw); err != nil {
return err
}
types, err := packedInts(raw, 63)
if err != nil || len(types) > 1 {
return fmt.Errorf("gamedata: invalid NPC quest type %d", quest)
}
var kind uint64
if len(types) == 1 {
kind = types[0]
}
npc.QuestTypes[quest] = kind
}
return rows.Err()
}
@@ -0,0 +1,47 @@
package gamedata
import (
"bd2server/internal/server/wire"
"database/sql"
"testing"
)
func TestRecruitNPCResolvesScoutParallelInteractionWithZeroPackField(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err = db.Exec("CREATE TABLE FieldNpcTable(id INTEGER PRIMARY KEY,ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
raw := wire.AppendVarint(nil, 8, 7)
raw = wire.AppendVarint(raw, 14, 9)
for _, value := range []uint64{1, 12, 4} {
raw = wire.AppendVarint(raw, 9, value)
}
for _, value := range []uint64{0, 30, 40} {
raw = wire.AppendVarint(raw, 12, value)
}
raw = wire.AppendVarint(raw, 18, 1)
raw = wire.AppendVarint(raw, 19, 2)
if _, err = db.Exec("INSERT INTO FieldNpcTable VALUES(7,?)", raw); err != nil {
t.Fatal(err)
}
npc, err := loadRecruitNPC(db, 7)
if err != nil || npc.ScoutID != 40 || npc.MapID != 9 || npc.QuestEnableTypes[0] != 1 {
t.Fatalf("npc=%+v err=%v", npc, err)
}
if _, err = loadRecruitNPC(db, 8); err == nil {
t.Fatal("accepted absent NPC")
}
if _, err = db.Exec("CREATE TABLE QuestTable1(id INTEGER PRIMARY KEY,ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
if _, err = db.Exec("INSERT INTO QuestTable1 VALUES(2,?)", wire.AppendVarint(nil, 63, 1)); err != nil {
t.Fatal(err)
}
if err = loadRecruitNPCQuestTypes(db, 1, &npc); err != nil || npc.QuestTypes[2] != 1 {
t.Fatalf("questtypes=%v err=%v", npc.QuestTypes, err)
}
}
@@ -0,0 +1,72 @@
package gamedata
import (
"bd2server/internal/server/wire"
"database/sql"
"strings"
"testing"
)
func TestRecruitSpecialDefaultsUseProtoZeroAndValidateConfiguration(t *testing.T) {
fields := map[int][]uint64{1: {2}, 2: {120}, 4: {50}, 6: {3}, 7: {99}}
d := RecruitDesign{}
if e := decodeRecruitSpecialDefaults(friendshipTestProto(fields), &d); e != nil {
t.Fatal(e)
}
if d.AppearCount != 2 || d.AutoResetMinute != 120 || d.ResetCount != 50 || d.ResetType != 3 || d.ResetLimit != 99 {
t.Fatalf("defaults %+v", d)
}
for _, field := range []int{1, 2, 4, 6, 7} {
copyFields := map[int][]uint64{}
for key, values := range fields {
copyFields[key] = values
}
delete(copyFields, field)
if e := decodeRecruitSpecialDefaults(friendshipTestProto(copyFields), &RecruitDesign{}); e == nil {
t.Fatalf("missing configuration field %d accepted", field)
}
}
fields[5] = []uint64{1}
if e := decodeRecruitSpecialDefaults(friendshipTestProto(fields), &RecruitDesign{}); e == nil {
t.Fatal("currency item ID accepted")
}
}
// Invalid rows must fail before any character/costume lookup can hide the
// actual malformed recruitment table behind a missing unrelated table.
func TestRecruitLoaderRejectsInvalidProtocolRows(t *testing.T) {
for _, tc := range []struct {
name string
fields map[int][]uint64
}{
{"identity", map[int][]uint64{2: {101}, 3: {11}, 5: {1}, 6: {71}, 7: {8}}},
{"partial materials", map[int][]uint64{2: {101}, 3: {10}, 5: {1}, 7: {8}}},
{"zero cost", map[int][]uint64{2: {101}, 3: {10}, 5: {0}, 6: {71}, 7: {8}}},
{"unsupported currency", map[int][]uint64{2: {101}, 3: {10}, 5: {1}, 6: {71}, 7: {3}}},
{"unsupported type", map[int][]uint64{2: {101}, 3: {10}, 5: {1}, 6: {71}, 7: {8}, 9: {2}}},
} {
t.Run(tc.name, func(t *testing.T) {
db, e := sql.Open("sqlite", ":memory:")
if e != nil {
t.Fatal(e)
}
defer db.Close()
if _, e = db.Exec("CREATE TABLE MercenaryScoutTable(id INTEGER,ProtoBuf BLOB)"); e != nil {
t.Fatal(e)
}
var proto []byte
for field, values := range tc.fields {
for _, v := range values {
proto = wire.AppendVarint(proto, field, v)
}
}
if _, e = db.Exec("INSERT INTO MercenaryScoutTable VALUES(10,?)", proto); e != nil {
t.Fatal(e)
}
_, e = loadRecruitDesign(db)
if e == nil || strings.Contains(e.Error(), "no such table") {
t.Fatalf("bad row not rejected by recruitment decoder: %v", e)
}
})
}
}
+383
View File
@@ -0,0 +1,383 @@
package player
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"math"
"math/big"
"sort"
"strconv"
"sync"
"time"
)
// RecruitNPCResolver must authorize the current pack/map/quest NPC and return
// its Scout interaction value. A client NPC ID alone grants no authority.
type RecruitNPCResolver func(uint64) (uint64, error)
// RecruitService participates in the enclosing account transaction, which
// atomically commits inventory costs, collection copies and completion/replay.
type RecruitService struct {
mu sync.Mutex
design *gamedata.RecruitDesign
catalog CostumeDesignSource
collection *CollectionStore
inventory *Inventory
wallet *Wallet
resolver RecruitNPCResolver
session string
now func() time.Time
}
func NewRecruitService(design *gamedata.RecruitDesign, catalog CostumeDesignSource, collection *CollectionStore, inventory *Inventory, wallet *Wallet, resolver RecruitNPCResolver) (*RecruitService, error) {
if design == nil || catalog == nil || collection == nil || inventory == nil || wallet == nil || resolver == nil {
return nil, errors.New("player: incomplete recruitment service")
}
for _, r := range design.Rules {
if _, ok := catalog.Character(r.CostumeID); !ok {
return nil, fmt.Errorf("player: recruit costume %d has no design", r.CostumeID)
}
}
return &RecruitService{design: design, catalog: catalog, collection: collection, inventory: inventory, wallet: wallet, resolver: resolver, now: time.Now}, nil
}
func (s *RecruitService) BeginSession(id string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = id }
func recruitIdentity(id uint64) string { return "recruit:" + strconv.FormatUint(id, 10) }
func (s *RecruitService) Handle(path string, request []byte) (int, []byte, bool, error) {
if path != "/MercenaryScout" && path != "/CharScoutInfo" && path != "/CharSpecialScoutBuy" && path != "/CharSpecialScoutReset" {
return 0, nil, false, nil
}
s.mu.Lock()
defer s.mu.Unlock()
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 || seq > math.MaxInt32 {
return 0, nil, true, errors.New("player: invalid recruitment sequence")
}
if path == "/CharScoutInfo" {
state, e := s.specialState()
if e != nil {
return 0, nil, true, e
}
if e = s.persistSpecial(state); e != nil {
return 0, nil, true, e
}
return 148, s.scoutInfo(state), true, nil
}
if s.session == "" {
return 0, nil, true, errors.New("player: recruitment requires authenticated session")
}
digestBytes := sha256.Sum256(request)
digest := hex.EncodeToString(digestBytes[:])
keyBytes := sha256.Sum256([]byte(s.session + ":" + path + ":" + strconv.FormatUint(seq, 10)))
replyKey := "recruit-reply:" + hex.EncodeToString(keyBytes[:])
if b, ok, e := s.collection.GachaBatchResponse(replyKey, digest); ok || e != nil {
return recruitCode(path), b, true, e
}
var state specialRecruitState
if path != "/MercenaryScout" {
state, err = s.specialState()
if err != nil {
return 0, nil, true, err
}
}
if path == "/CharSpecialScoutReset" {
if state.Count >= s.design.ResetLimit {
return 0, nil, true, errors.New("player: scout reset limit reached")
}
if !s.wallet.CanSpendFreeJewelry(s.design.ResetCount) {
return 0, nil, true, errors.New("player: insufficient scout reset jewelry")
}
state, err = s.rollSpecial(state.Count + 1)
if err != nil {
return 0, nil, true, err
}
if _, err = s.wallet.SpendFreeJewelryOnce(replyKey, s.design.ResetCount); err != nil {
return 0, nil, true, err
}
if err = s.persistSpecial(state); err != nil {
return 0, nil, true, err
}
var body []byte
for _, id := range state.IDs {
body = wire.AppendVarint(body, 1, id)
}
body = wire.AppendVarint(body, 2, state.Next)
if err = s.collection.RecordGachaBatch(replyKey, digest, body); err != nil {
return 0, nil, true, err
}
return 150, body, true, nil
}
npc, found, err := wire.Varint(request, 2)
if err != nil || !found || npc == 0 || npc > math.MaxInt32 {
return 0, nil, true, errors.New("player: invalid recruit NPC")
}
id := npc
if path == "/MercenaryScout" {
id, err = s.resolver(npc)
if err != nil {
return 0, nil, true, err
}
}
r, ok := s.design.Rules[id]
if !ok {
return 0, nil, true, errors.New("player: missing recruit rule")
}
if path == "/MercenaryScout" && r.Type != 0 {
return 0, nil, true, errors.New("player: NPC is not ordinary recruit")
}
if path == "/CharSpecialScoutBuy" {
available := false
for _, v := range state.IDs {
if v == id {
available = true
}
}
if r.Type != 1 || !available {
return 0, nil, true, errors.New("player: special recruit is not appearing")
}
}
identity := recruitIdentity(id)
if _, ok := s.collection.Grant(identity); ok {
return 0, nil, true, errors.New("player: character already recruited")
}
materials, err := equipmentRequestItems(request, 3, "MercenaryScout")
if err != nil {
return 0, nil, true, err
}
items, gold, err := validateCostumeBurstMaterials(r.Costs, materials)
if err != nil || gold != 0 {
return 0, nil, true, fmt.Errorf("player: invalid recruit materials: %v", err)
}
if err = s.inventory.CanConsume(items); err != nil {
return 0, nil, true, err
}
if err = s.inventory.Consume(items); err != nil {
return 0, nil, true, err
}
grant, err := s.collection.GrantCostumes(identity, []uint64{r.CostumeID}, s.catalog)
if err != nil {
return 0, nil, true, err
}
var rewards []gamedata.Reward
for _, x := range grant.Exchanges {
rewards = append(rewards, gamedata.Reward{Type: x.ExchangeItemType, ID: x.ExchangeItemID, Count: x.ExchangeCount})
}
if len(rewards) > 0 {
if _, err = s.wallet.GrantQuestOnce(identity+":exchange", rewards); err != nil {
return 0, nil, true, err
}
}
if path == "/CharSpecialScoutBuy" {
if err = s.persistSpecial(state); err != nil {
return 0, nil, true, err
}
}
body := wire.AppendBytes(nil, 1, recruitRewardBundle(s.collection, grant))
if err = s.collection.RecordGachaBatch(replyKey, digest, body); err != nil {
return 0, nil, true, err
}
return recruitCode(path), body, true, nil
}
func recruitCode(path string) int {
if path == "/CharSpecialScoutBuy" {
return 149
}
if path == "/CharSpecialScoutReset" {
return 150
}
return 13
}
type specialRecruitState struct {
IDs []uint64
Count, Next uint64
Day string
}
func (s *CollectionStore) saveRecruitState(body []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
next := cloneCollection(s.data)
next.Grants["special-recruit-state"] = CollectionGrant{GachaResponse: append([]byte(nil), body...)}
return s.commit(next)
}
func (s *RecruitService) specialState() (specialRecruitState, error) {
g, ok := s.collection.Grant("special-recruit-state")
if !ok {
return s.rollSpecial(0)
}
ids, e := recruitIDs(g.GachaResponse, 1)
if e != nil {
return specialRecruitState{}, e
}
count, _, e := wire.Varint(g.GachaResponse, 2)
if e != nil {
return specialRecruitState{}, e
}
next, _, e := wire.Varint(g.GachaResponse, 3)
if e != nil {
return specialRecruitState{}, e
}
dayBytes, dayOK, e := wire.Bytes(g.GachaResponse, 4)
if e != nil || !dayOK {
return specialRecruitState{}, errors.New("player: missing scout rotation day")
}
day := string(dayBytes)
if _, e = time.Parse("2006-01-02", day); e != nil {
return specialRecruitState{}, errors.New("player: invalid scout rotation day")
}
state := specialRecruitState{IDs: ids, Count: count, Next: next, Day: day}
if e != nil {
return state, e
}
seen := map[uint64]bool{}
if next == 0 || next > math.MaxInt64 || count > s.design.ResetLimit || len(ids) > int(s.design.AppearCount) {
return state, errors.New("player: invalid saved scout rotation")
}
for _, id := range ids {
r, ok := s.design.Rules[id]
if !ok || r.Type != 1 || r.AppearProb == 0 || seen[id] {
return state, errors.New("player: invalid saved scout appearance")
}
seen[id] = true
}
if state.Day != s.now().UTC().Format("2006-01-02") {
state.Count = 0
state.Day = s.now().UTC().Format("2006-01-02")
}
if next <= uint64(s.now().UnixMilli()) {
return s.rollSpecial(state.Count)
}
return state, nil
}
// The local rotation policy uses GameData weights without replacement. Its
// distribution is a server policy; official initial/random state is unknown.
func (s *RecruitService) rollSpecial(count uint64) (specialRecruitState, error) {
state := specialRecruitState{Day: s.now().UTC().Format("2006-01-02"), Count: count, Next: uint64(s.now().Add(time.Duration(s.design.AutoResetMinute) * time.Minute).UnixMilli())}
var pool []uint64
for id, r := range s.design.Rules {
if r.Type == 1 && r.AppearProb > 0 {
if _, done := s.collection.Grant(recruitIdentity(id)); !done {
pool = append(pool, id)
}
}
}
sort.Slice(pool, func(i, j int) bool { return pool[i] < pool[j] })
for len(state.IDs) < int(s.design.AppearCount) && len(pool) > 0 {
var total uint64
for _, id := range pool {
if total > math.MaxUint64-s.design.Rules[id].AppearProb {
return state, errors.New("player: scout appearance weight overflow")
}
total += s.design.Rules[id].AppearProb
}
draw, e := rand.Int(rand.Reader, new(big.Int).SetUint64(total))
if e != nil {
return state, e
}
n := draw.Uint64()
for i, id := range pool {
w := s.design.Rules[id].AppearProb
if n < w {
state.IDs = append(state.IDs, id)
pool = append(pool[:i], pool[i+1:]...)
break
}
n -= w
}
}
return state, nil
}
func (s *RecruitService) persistSpecial(state specialRecruitState) error {
var b []byte
for _, id := range state.IDs {
b = wire.AppendVarint(b, 1, id)
}
b = wire.AppendVarint(b, 2, state.Count)
b = wire.AppendVarint(b, 3, state.Next)
b = wire.AppendString(b, 4, state.Day)
return s.collection.saveRecruitState(b)
}
func (s *RecruitService) scoutInfo(state specialRecruitState) []byte {
var b []byte
for _, id := range state.IDs {
if _, done := s.collection.Grant(recruitIdentity(id)); !done {
b = wire.AppendVarint(b, 1, id)
}
}
b = wire.AppendVarint(b, 2, state.Count)
b = wire.AppendVarint(b, 3, state.Next)
var ids []uint64
for id := range s.design.Rules {
if _, ok := s.collection.Grant(recruitIdentity(id)); ok {
ids = append(ids, id)
}
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for _, id := range ids {
b = wire.AppendVarint(b, 4, id)
}
return b
}
func recruitRewardBundle(c *CollectionStore, g CollectionGrant) []byte {
var b []byte
for _, idx := range g.CharacterIndices {
if v, ok := c.CharacterByIndex(idx); ok {
b = wire.AppendBytes(b, 2, CharacterWire(v))
b = wire.AppendBytes(b, 6, ItemWire(Item{ID: v.ID, Type: 6, Count: 1}))
}
}
for _, idx := range g.CostumeIndices {
if v, ok := c.CostumeByIndex(idx); ok {
b = wire.AppendBytes(b, 3, CostumeWire(v))
}
}
for _, id := range g.ViewCostumeIDs {
b = wire.AppendBytes(b, 6, ItemWire(Item{ID: id, Type: 11, Count: 1}))
}
for _, u := range g.Upgrades {
v := wire.AppendVarint(nil, 1, u.InvenIndex)
v = wire.AppendVarint(v, 2, 11)
v = wire.AppendVarint(v, 3, u.CostumeID)
v = wire.AppendVarint(v, 4, u.Before)
v = wire.AppendVarint(v, 5, u.After)
b = wire.AppendBytes(b, 9, v)
}
for _, x := range g.Exchanges {
v := wire.AppendVarint(nil, 1, x.OriginalItemType)
v = wire.AppendVarint(v, 2, x.OriginalItemID)
v = wire.AppendVarint(v, 3, x.OriginalCount)
v = wire.AppendVarint(v, 4, x.ExchangeItemType)
v = wire.AppendVarint(v, 5, x.ExchangeItemID)
v = wire.AppendVarint(v, 6, x.ExchangeCount)
b = wire.AppendBytes(b, 8, v)
r := wire.AppendVarint(nil, 2, x.ExchangeItemType)
r = wire.AppendVarint(r, 3, x.ExchangeCount)
b = wire.AppendBytes(b, 10, r)
}
return b
}
func recruitIDs(b []byte, n int) ([]uint64, error) {
var ids []uint64
err := wire.Walk(b, func(f wire.Field) error {
if f.Number == n {
if f.Type != 0 {
return errors.New("player: invalid scout state IDs")
}
id, _ := binary.Uvarint(f.Value)
ids = append(ids, id)
}
return nil
})
return ids, err
}
+314
View File
@@ -0,0 +1,314 @@
package player
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
"bytes"
"errors"
"path/filepath"
"testing"
"time"
)
type failingRecruitCatalog struct {
design CostumeDesignSource
fail bool
}
func (c *failingRecruitCatalog) Character(id uint64) (gamedata.CharacterDesign, bool) {
if c.fail {
return gamedata.CharacterDesign{}, false
}
return c.design.Character(id)
}
func TestRecruitSQLiteFailureAfterConsumeRollsBackAllDomains(t *testing.T) {
fixture, _ := recruitFixture(t, filepath.Join(t.TempDir(), "design.json"))
path := filepath.Join(t.TempDir(), "state.db")
repo, e := accountstate.Open(path)
if e != nil {
t.Fatal(e)
}
starter := &Starter{Version: "2.35.10"}
inventory, e := OpenInventory(repo, starter)
if e != nil {
t.Fatal(e)
}
items, e := inventory.GrantOnce("tickets", []gamedata.BattleReward{{Type: 8, ID: 71, Count: 2}})
if e != nil {
t.Fatal(e)
}
wallet, e := OpenWallet(repo, Currency{FreeJewelry: 500})
if e != nil {
t.Fatal(e)
}
collection, e := OpenCollectionStore(repo, nil)
if e != nil {
t.Fatal(e)
}
for _, ensure := range []func() error{inventory.EnsurePersisted, wallet.EnsurePersisted, collection.EnsurePersisted} {
if e = ensure(); e != nil {
t.Fatal(e)
}
}
catalog := &failingRecruitCatalog{design: fixture.catalog}
s, e := NewRecruitService(fixture.design, catalog, collection, inventory, wallet, fixture.resolver)
if e != nil {
t.Fatal(e)
}
s.BeginSession("session")
s.now = fixture.now
op, e := repo.BeginOperation()
if e != nil {
t.Fatal(e)
}
catalog.fail = true
ticket := items[0]
ticket.Count = 1
if _, _, _, e = s.Handle("/MercenaryScout", costumeBurstRequest(1, 99, ticket)); e == nil {
t.Fatal("expected grant failure after material deduction")
}
if e = op.Rollback(); e == nil {
t.Fatal("dirty rollback must fence published in-memory state")
}
if e = repo.Close(); e != nil {
t.Fatal(e)
}
repo, e = accountstate.Open(path)
if e != nil {
t.Fatal(e)
}
defer repo.Close()
inventory, e = OpenInventory(repo, starter)
if e != nil {
t.Fatal(e)
}
wallet, e = OpenWallet(repo, Currency{})
if e != nil {
t.Fatal(e)
}
collection, e = OpenCollectionStore(repo, nil)
if e != nil {
t.Fatal(e)
}
if e = inventory.CanConsume(items); e != nil {
t.Fatalf("rollback lost ticket: %v", e)
}
if wallet.Snapshot().FreeJewelry != 500 || len(collection.Costumes()) != 0 || len(collection.Characters()) != 0 {
t.Fatal("rollback committed partial rewards/currency")
}
if _, ok := collection.Grant(recruitIdentity(10)); ok {
t.Fatal("rollback committed completion")
}
if _, ok := collection.Grant("special-recruit-state"); ok {
t.Fatal("normal rollback wrote rotation")
}
catalog.fail = false
s, e = NewRecruitService(fixture.design, catalog, collection, inventory, wallet, fixture.resolver)
if e != nil {
t.Fatal(e)
}
s.BeginSession("session")
s.now = fixture.now
op, e = repo.BeginOperation()
if e != nil {
t.Fatal(e)
}
if _, _, _, e = s.Handle("/MercenaryScout", costumeBurstRequest(1, 99, ticket)); e != nil {
t.Fatal(e)
}
if e = op.Commit(); e != nil {
t.Fatal(e)
}
if _, ok := collection.Grant(recruitIdentity(10)); !ok {
t.Fatal("successful retry not committed")
}
}
func recruitFixture(t *testing.T, path string) (*RecruitService, Item) {
t.Helper()
store := testStore(path)
inventory, e := OpenInventory(store, &Starter{Version: "2.35.10"})
if e != nil {
t.Fatal(e)
}
items, e := inventory.GrantOnce("tickets", []gamedata.BattleReward{{Type: 8, ID: 71, Count: 10}})
if e != nil {
t.Fatal(e)
}
wallet, e := OpenWallet(store, Currency{FreeJewelry: 500})
if e != nil {
t.Fatal(e)
}
collection, e := OpenCollectionStore(store, nil)
if e != nil {
t.Fatal(e)
}
d := &gamedata.RecruitDesign{Rules: map[uint64]gamedata.RecruitRule{}, Characters: map[uint64]gamedata.CharacterDesign{}, AppearCount: 2, AutoResetMinute: 120, ResetCount: 50, ResetType: 3, ResetLimit: 2}
for _, id := range []uint64{10, 20, 30, 40} {
r := gamedata.RecruitRule{ID: id, CostumeID: id*10 + 1, Type: 1, AppearProb: id, Costs: []gamedata.PromotionCost{{Type: 8, ID: 71, Count: 1}}}
if id == 10 {
r.Type = 0
}
d.Rules[id] = r
d.Characters[r.CostumeID] = gamedata.CharacterDesign{ID: id, HP: 100, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 100}
}
s, e := NewRecruitService(d, d, collection, inventory, wallet, func(n uint64) (uint64, error) {
if n != 99 {
return 0, errors.New("unauthorized NPC")
}
return 10, nil
})
if e != nil {
t.Fatal(e)
}
s.BeginSession("session")
s.now = func() time.Time { return time.UnixMilli(2000000000000) }
item := items[0]
item.Count = 1
return s, item
}
func TestRecruitSpecialPersistsAppearanceCompletesAndReplays(t *testing.T) {
path := filepath.Join(t.TempDir(), "account.json")
s, ticket := recruitFixture(t, path)
req := wire.AppendVarint(nil, 1, 1)
code, body, _, e := s.Handle("/CharScoutInfo", req)
if e != nil || code != 148 {
t.Fatalf("info %d %v", code, e)
}
ids, e := recruitIDs(body, 1)
if e != nil || len(ids) != 2 || ids[0] == ids[1] {
t.Fatalf("appearance %v %v", ids, e)
}
request := costumeBurstRequest(2, ids[0], ticket)
code, first, _, e := s.Handle("/CharSpecialScoutBuy", request)
if e != nil || code != 149 {
t.Fatalf("buy %d %v", code, e)
}
if _, ok := s.collection.Grant(recruitIdentity(ids[0])); !ok {
t.Fatal("completion missing")
}
if _, ok := s.collection.CostumeByID(ids[0]*10 + 1); !ok {
t.Fatal("costume missing")
}
_, second, _, e := s.Handle("/CharSpecialScoutBuy", request)
if e != nil || !bytes.Equal(first, second) {
t.Fatalf("replay %v", e)
}
if _, _, _, e = s.Handle("/CharSpecialScoutBuy", costumeBurstRequest(3, ids[0], ticket)); e == nil {
t.Fatal("repeat recruitment accepted")
}
store := testStore(path)
inventory, e := OpenInventory(store, &Starter{Version: "2.35.10"})
if e != nil {
t.Fatal(e)
}
wallet, e := OpenWallet(store, Currency{})
if e != nil {
t.Fatal(e)
}
collection, e := OpenCollectionStore(store, nil)
if e != nil {
t.Fatal(e)
}
restored, e := NewRecruitService(s.design, s.catalog, collection, inventory, wallet, s.resolver)
if e != nil {
t.Fatal(e)
}
restored.BeginSession("session")
restored.now = s.now
_, again, _, e := restored.Handle("/CharSpecialScoutBuy", request)
if e != nil || !bytes.Equal(again, first) {
t.Fatalf("durable replay %v", e)
}
_, info, _, e := restored.Handle("/CharScoutInfo", req)
completed, _ := recruitIDs(info, 4)
appearing, _ := recruitIDs(info, 1)
if e != nil || len(completed) != 1 || completed[0] != ids[0] || len(appearing) != 1 {
t.Fatalf("restored %x %v", info, e)
}
remaining := ticket
remaining.Count = 9
if e = inventory.CanConsume([]Item{remaining}); e != nil {
t.Fatalf("double charged %v", e)
}
}
func TestRecruitNormalAuthorizesNPCAndRejectsForgedCosts(t *testing.T) {
s, ticket := recruitFixture(t, filepath.Join(t.TempDir(), "account.json"))
for _, req := range [][]byte{costumeBurstRequest(1, 98, ticket), costumeBurstRequest(2, 99, Item{InvenIndex: ticket.InvenIndex, ID: 71, Type: 8, Count: 2}), costumeBurstRequest(3, 20, ticket)} {
if _, _, _, e := s.Handle("/MercenaryScout", req); e == nil {
t.Fatal("invalid normal recruit accepted")
}
}
if _, exists := s.collection.Grant("special-recruit-state"); exists {
t.Fatal("rejected normal recruit wrote rotation")
}
code, body, _, e := s.Handle("/MercenaryScout", costumeBurstRequest(4, 99, ticket))
if e != nil || code != 13 {
t.Fatalf("normal %d %v", code, e)
}
bundle, ok, e := wire.Bytes(body, 1)
if e != nil || !ok {
t.Fatal("missing reward bundle")
}
if _, ok, e = wire.Bytes(bundle, 2); e != nil || !ok {
t.Fatal("missing character")
}
if _, ok, e = wire.Bytes(bundle, 3); e != nil || !ok {
t.Fatal("missing costume")
}
}
func TestRecruitInvalidSpecialRequestsDoNotPersistRotation(t *testing.T) {
s, ticket := recruitFixture(t, filepath.Join(t.TempDir(), "account.json"))
if _, _, _, e := s.Handle("/CharSpecialScoutBuy", costumeBurstRequest(1, 999, ticket)); e == nil {
t.Fatal("unknown recruit accepted")
}
if _, exists := s.collection.Grant("special-recruit-state"); exists {
t.Fatal("invalid buy wrote rotation")
}
s.wallet.state.Currency.FreeJewelry = 0
if _, _, _, e := s.Handle("/CharSpecialScoutReset", wire.AppendVarint(nil, 1, 2)); e == nil {
t.Fatal("unaffordable reset accepted")
}
if _, exists := s.collection.Grant("special-recruit-state"); exists {
t.Fatal("invalid reset wrote rotation")
}
}
func TestRecruitResetUsesFreeJewelryLimitReplayAndAutomaticDeadline(t *testing.T) {
s, _ := recruitFixture(t, filepath.Join(t.TempDir(), "account.json"))
request := wire.AppendVarint(nil, 1, 1)
code, first, _, e := s.Handle("/CharSpecialScoutReset", request)
if e != nil || code != 150 {
t.Fatalf("reset %d %v", code, e)
}
if s.wallet.Snapshot().FreeJewelry != 450 {
t.Fatal("reset cost")
}
_, again, _, e := s.Handle("/CharSpecialScoutReset", request)
if e != nil || !bytes.Equal(first, again) || s.wallet.Snapshot().FreeJewelry != 450 {
t.Fatal("reset replay charged")
}
if _, _, _, e = s.Handle("/CharSpecialScoutReset", wire.AppendVarint(nil, 1, 2)); e != nil {
t.Fatal(e)
}
if _, _, _, e = s.Handle("/CharSpecialScoutReset", wire.AppendVarint(nil, 1, 3)); e == nil {
t.Fatal("reset limit ignored")
}
s.now = func() time.Time { return time.UnixMilli(2000000000000).Add(121 * time.Minute) }
_, body, _, e := s.Handle("/CharScoutInfo", wire.AppendVarint(nil, 1, 4))
count, _, _ := wire.Varint(body, 2)
next, _, _ := wire.Varint(body, 3)
if e != nil || count != 2 || next <= uint64(s.now().UnixMilli()) {
t.Fatalf("automatic reset %x %v", body, e)
}
s.now = func() time.Time { return time.UnixMilli(2000000000000).Add(24 * time.Hour) }
_, body, _, e = s.Handle("/CharScoutInfo", wire.AppendVarint(nil, 1, 5))
count, _, _ = wire.Varint(body, 2)
if e != nil || count != 0 {
t.Fatal("daily reset did not clear manual count")
}
if s.wallet.Snapshot().FreeJewelry != 400 {
t.Fatal("automatic reset charged")
}
}
+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 {
-15
View File
@@ -1313,21 +1313,6 @@
}
]
},
"/CharScoutInfo": {
"packet_code": 0,
"fields": [
{
"number": 1,
"type": 2,
"bytes": "6jDALw=="
},
{
"number": 3,
"type": 0,
"varint": 1789842805819
}
]
},
"/ColosseumUserInfo": {
"packet_code": 473,
"fields": [