diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index c7633ff..afeba01 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -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, diff --git a/go/internal/server/gamedata/pack_summary.go b/go/internal/server/gamedata/pack_summary.go new file mode 100644 index 0000000..13a6c3f --- /dev/null +++ b/go/internal/server/gamedata/pack_summary.go @@ -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() +} diff --git a/go/internal/server/gamedata/pack_summary_test.go b/go/internal/server/gamedata/pack_summary_test.go new file mode 100644 index 0000000..0fb5aaa --- /dev/null +++ b/go/internal/server/gamedata/pack_summary_test.go @@ -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") + } +} diff --git a/go/internal/server/gamedata/recruit.go b/go/internal/server/gamedata/recruit.go new file mode 100644 index 0000000..604d29b --- /dev/null +++ b/go/internal/server/gamedata/recruit.go @@ -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 +} diff --git a/go/internal/server/gamedata/recruit_npc.go b/go/internal/server/gamedata/recruit_npc.go new file mode 100644 index 0000000..0412723 --- /dev/null +++ b/go/internal/server/gamedata/recruit_npc.go @@ -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() +} diff --git a/go/internal/server/gamedata/recruit_npc_test.go b/go/internal/server/gamedata/recruit_npc_test.go new file mode 100644 index 0000000..f84c2e7 --- /dev/null +++ b/go/internal/server/gamedata/recruit_npc_test.go @@ -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) + } +} diff --git a/go/internal/server/gamedata/recruit_test.go b/go/internal/server/gamedata/recruit_test.go new file mode 100644 index 0000000..0797dd7 --- /dev/null +++ b/go/internal/server/gamedata/recruit_test.go @@ -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) + } + }) + } +} diff --git a/go/internal/server/player/recruit.go b/go/internal/server/player/recruit.go new file mode 100644 index 0000000..6ef3ad9 --- /dev/null +++ b/go/internal/server/player/recruit.go @@ -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 +} diff --git a/go/internal/server/player/recruit_test.go b/go/internal/server/player/recruit_test.go new file mode 100644 index 0000000..dc58012 --- /dev/null +++ b/go/internal/server/player/recruit_test.go @@ -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") + } +} diff --git a/go/internal/server/world/pack_summary.go b/go/internal/server/world/pack_summary.go new file mode 100644 index 0000000..cf47e75 --- /dev/null +++ b/go/internal/server/world/pack_summary.go @@ -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 +} diff --git a/go/internal/server/world/pack_summary_test.go b/go/internal/server/world/pack_summary_test.go new file mode 100644 index 0000000..0860774 --- /dev/null +++ b/go/internal/server/world/pack_summary_test.go @@ -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) + } + } +} diff --git a/go/internal/server/world/recruit_npc.go b/go/internal/server/world/recruit_npc.go new file mode 100644 index 0000000..69bbd85 --- /dev/null +++ b/go/internal/server/world/recruit_npc.go @@ -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 +} diff --git a/go/internal/server/world/recruit_npc_test.go b/go/internal/server/world/recruit_npc_test.go new file mode 100644 index 0000000..505631f --- /dev/null +++ b/go/internal/server/world/recruit_npc_test.go @@ -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) + } +} diff --git a/go/internal/server/world/world.go b/go/internal/server/world/world.go index 11df020..f7dfa40 100644 --- a/go/internal/server/world/world.go +++ b/go/internal/server/world/world.go @@ -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 { diff --git a/go/seed/v2_35_10/readonly.json b/go/seed/v2_35_10/readonly.json index b8f3e0c..2f9c200 100644 --- a/go/seed/v2_35_10/readonly.json +++ b/go/seed/v2_35_10/readonly.json @@ -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": [