feat: persist recruitment and serve pack reward summaries
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user