From 013c4dcf1cd0f827a0033c581452fd01e0facc4c Mon Sep 17 00:00:00 2001 From: Flechazo <2558755403@qq.com> Date: Sun, 4 Oct 2026 14:55:25 +0800 Subject: [PATCH] fix(server): load story chapters and unlock rewards from GameData --- go/cmd/bd2server/main.go | 3 + go/internal/server/feature/feature_test.go | 2 +- go/internal/server/feature/standalone.go | 1 - go/internal/server/gamedata/pack_jam.go | 61 +++++ go/internal/server/gamedata/pack_jam_test.go | 33 +++ .../server/gamedata/pack_transition.go | 64 ----- go/internal/server/gamedata/quest_costume.go | 32 +++ go/internal/server/gamedata/quests.go | 16 +- go/internal/server/gamedata/story_catalog.go | 225 ++++++++++++++++++ .../server/gamedata/story_catalog_test.go | 86 +++++++ go/internal/server/player/quest_rewards.go | 8 + go/internal/server/world/field_pack_test.go | 1 + go/internal/server/world/health_test.go | 2 + go/internal/server/world/pack_jam.go | 53 +++++ go/internal/server/world/pack_jam_test.go | 105 ++++++++ go/internal/server/world/pack_purchase.go | 111 +++++++++ .../server/world/pack_purchase_test.go | 108 +++++++++ go/internal/server/world/pack_summary_test.go | 10 + go/internal/server/world/recruit_npc_test.go | 1 + go/internal/server/world/story_catalog.go | 114 +++++++++ .../server/world/story_catalog_test.go | 104 ++++++++ go/internal/server/world/world.go | 223 ++++++++--------- go/internal/server/world/world_test.go | 46 +++- 23 files changed, 1215 insertions(+), 194 deletions(-) create mode 100644 go/internal/server/gamedata/pack_jam.go create mode 100644 go/internal/server/gamedata/pack_jam_test.go create mode 100644 go/internal/server/gamedata/quest_costume.go create mode 100644 go/internal/server/gamedata/story_catalog.go create mode 100644 go/internal/server/gamedata/story_catalog_test.go create mode 100644 go/internal/server/player/quest_rewards.go create mode 100644 go/internal/server/world/pack_jam.go create mode 100644 go/internal/server/world/pack_jam_test.go create mode 100644 go/internal/server/world/pack_purchase.go create mode 100644 go/internal/server/world/pack_purchase_test.go create mode 100644 go/internal/server/world/story_catalog.go create mode 100644 go/internal/server/world/story_catalog_test.go diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index afeba01..139efd4 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -676,6 +676,9 @@ func serve(args []string) (serveErr error) { ); err != nil { return fmt.Errorf("initialize complete account state generation: %w", err) } + if err := worldService.EnsureInitialPackPurchase(); err != nil { + return fmt.Errorf("grant initial pack purchase rewards: %w", err) + } if err := stateRepository.MarkInitializationComplete(); err != nil { return fmt.Errorf("mark account initialization complete: %w", err) } diff --git a/go/internal/server/feature/feature_test.go b/go/internal/server/feature/feature_test.go index 44b3cb7..a1d87b0 100644 --- a/go/internal/server/feature/feature_test.go +++ b/go/internal/server/feature/feature_test.go @@ -28,7 +28,7 @@ func TestHandleRejectsUnknownAndInvalidRequests(t *testing.T) { if _, _, ok, err := Handle("/CharAwakeInfo", wire.AppendVarint(nil, 1, 1)); ok || err != nil { t.Fatalf("stateful CharAwakeInfo must not be handled by feature defaults: ok=%v err=%v", ok, err) } - for _, path := range []string{"/PresetInfo", "/DeckCostumeSettingInfo", "/FriendshipInfo", "/MasterTitleInfo", "/MasterTitleInfoUpdate"} { + for _, path := range []string{"/PackPreviewInfo", "/PackJamEvent", "/PresetInfo", "/DeckCostumeSettingInfo", "/FriendshipInfo", "/MasterTitleInfo", "/MasterTitleInfoUpdate"} { if _, _, ok, err := Handle(path, wire.AppendVarint(nil, 1, 1)); ok || err != nil { t.Fatalf("stateful %s must not be handled by feature defaults: ok=%v err=%v", path, ok, err) } diff --git a/go/internal/server/feature/standalone.go b/go/internal/server/feature/standalone.go index 5ea7b31..0838585 100644 --- a/go/internal/server/feature/standalone.go +++ b/go/internal/server/feature/standalone.go @@ -20,7 +20,6 @@ var standaloneDefaults = map[string]int{ "/MonsterHuntDeckInfo": 263, "/Attendance": 0, "/AttendanceInfo": 0, - "/PackPreviewInfo": 104, // No PvP decks, battle history or one-time reward claims have been saved. // Their repeated-only protobufs encode these account states as empty, and // the client's callbacks continue the arena-lobby entry sequence. diff --git a/go/internal/server/gamedata/pack_jam.go b/go/internal/server/gamedata/pack_jam.go new file mode 100644 index 0000000..fb75be3 --- /dev/null +++ b/go/internal/server/gamedata/pack_jam.go @@ -0,0 +1,61 @@ +package gamedata + +import ( + "database/sql" + "fmt" +) + +// PackJamDesign is the installed client's insertion animation and reward rule. +type PackJamDesign struct { + InsertMin, InsertMax uint64 + Reward Reward +} + +func LoadPackJamDesign(root, version string) (*PackJamDesign, error) { + db, closeDB, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer closeDB() + return loadPackJamDesign(db) +} + +func loadPackJamDesign(db *sql.DB) (*PackJamDesign, error) { + rows, err := db.Query("SELECT id,ProtoBuf FROM PackJamEventTable ORDER BY id") + if err != nil { + return nil, err + } + defer rows.Close() + var design *PackJamDesign + for rows.Next() { + var id int + var raw []byte + if err := rows.Scan(&id, &raw); err != nil { + return nil, err + } + if id != 0 || design != nil { + return nil, fmt.Errorf("gamedata: unsupported pack jam rule %d", id) + } + values := map[int]uint64{} + for field := 1; field <= 6; field++ { + v, err := packedInts(raw, field) + if err != nil || len(v) > 1 { + return nil, fmt.Errorf("gamedata: invalid pack jam field %d", field) + } + if len(v) == 1 { + values[field] = v[0] + } + } + if values[1] != 0 || values[3] == 0 || values[2] < values[3] || values[2] > uint64(^uint32(0)>>1) || values[4] == 0 || values[4] > uint64(^uint32(0)>>1) || values[6] != 3 || values[5] != 0 { + return nil, fmt.Errorf("gamedata: unsupported pack jam reward or insertion rule") + } + design = &PackJamDesign{InsertMin: values[3], InsertMax: values[2], Reward: Reward{Count: values[4], ID: values[5], Type: values[6]}} + } + if err := rows.Err(); err != nil { + return nil, err + } + if design == nil { + return nil, fmt.Errorf("gamedata: missing pack jam rule") + } + return design, nil +} diff --git a/go/internal/server/gamedata/pack_jam_test.go b/go/internal/server/gamedata/pack_jam_test.go new file mode 100644 index 0000000..68fa3ef --- /dev/null +++ b/go/internal/server/gamedata/pack_jam_test.go @@ -0,0 +1,33 @@ +package gamedata + +import ( + "bd2server/internal/server/wire" + "database/sql" + "testing" +) + +func TestPackJamDesignLoadsDefaultsAndRejectsUnsupportedRewards(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec("CREATE TABLE PackJamEventTable(id INTEGER,ProtoBuf BLOB)"); err != nil { + t.Fatal(err) + } + raw := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 2, 5), 3, 5), 4, 150), 6, 3) + if _, err := db.Exec("INSERT INTO PackJamEventTable VALUES(0,?)", raw); err != nil { + t.Fatal(err) + } + design, err := loadPackJamDesign(db) + if err != nil || design.InsertMin != 5 || design.InsertMax != 5 || design.Reward.ID != 0 || design.Reward.Type != 3 || design.Reward.Count != 150 { + t.Fatalf("design=%+v err=%v", design, err) + } + raw = wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 2, 5), 3, 5), 4, 150), 6, 4) + if _, err := db.Exec("UPDATE PackJamEventTable SET ProtoBuf=?", raw); err != nil { + t.Fatal(err) + } + if _, err := loadPackJamDesign(db); err == nil { + t.Fatal("unsupported currency accepted") + } +} diff --git a/go/internal/server/gamedata/pack_transition.go b/go/internal/server/gamedata/pack_transition.go index fbb6412..a78e048 100644 --- a/go/internal/server/gamedata/pack_transition.go +++ b/go/internal/server/gamedata/pack_transition.go @@ -7,70 +7,6 @@ import ( "path/filepath" ) -// LoadQuestPackChain opens the encrypted quest database once and follows the -// authoritative PackTable.NextPackId chain. Every pack receives its own quest -// map because quest IDs are only unique inside a pack. -func LoadQuestPackChain(root, version string, startPackID int) (map[int]map[int]QuestDesign, map[int]PackTransition, error) { - if startPackID <= 0 { - return nil, nil, fmt.Errorf("gamedata: invalid story start pack %d", startPackID) - } - plain, err := ReadQuestDatabase(root, version) - if err != nil { - return nil, nil, err - } - dir, err := os.MkdirTemp("", "bd2-pack-chain-") - if err != nil { - return nil, nil, err - } - defer os.RemoveAll(dir) - path := filepath.Join(dir, "common.db") - if err := os.WriteFile(path, plain, 0o600); err != nil { - return nil, nil, err - } - db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro") - if err != nil { - return nil, nil, err - } - defer db.Close() - - packs := make(map[int]map[int]QuestDesign) - transitions := make(map[int]PackTransition) - visited := make(map[int]bool) - for packID := startPackID; packID != 0; { - if visited[packID] { - return nil, nil, fmt.Errorf("gamedata: cyclic next-pack edge at %d", packID) - } - if len(visited) >= 64 { - return nil, nil, fmt.Errorf("gamedata: story pack chain exceeds 64 packs") - } - visited[packID] = true - quests, err := loadQuestDesignDB(db, packID) - if err != nil { - return nil, nil, err - } - if len(quests) == 0 { - return nil, nil, fmt.Errorf("gamedata: QuestTable%d is empty", packID) - } - packs[packID] = quests - - var raw []byte - if err := db.QueryRow("SELECT ProtoBuf FROM PackTable WHERE id=?", packID).Scan(&raw); err != nil { - return nil, nil, fmt.Errorf("gamedata: pack %d: %w", packID, err) - } - next, err := packedInts(raw, 45) - if err != nil || len(next) > 1 { - return nil, nil, fmt.Errorf("gamedata: pack %d has malformed next-pack edge", packID) - } - nextPackID := 0 - if len(next) == 1 { - nextPackID = int(next[0]) - } - transitions[packID] = PackTransition{PackID: packID, NextPackID: nextPackID} - packID = nextPackID - } - return packs, transitions, nil -} - // PackTransition is the authoritative story edge in PackTable. It is kept // separate from quest IDs because each story pack owns its own QuestTable. type PackTransition struct { diff --git a/go/internal/server/gamedata/quest_costume.go b/go/internal/server/gamedata/quest_costume.go new file mode 100644 index 0000000..faed907 --- /dev/null +++ b/go/internal/server/gamedata/quest_costume.go @@ -0,0 +1,32 @@ +package gamedata + +// LoadQuestCostumes loads only costume rewards referenced by the installed +// quest catalog, using the same character and duplicate-copy metadata as gacha. +func LoadQuestCostumes(root, version string, packs map[int]map[int]QuestDesign) (*RegularGachaCatalog, error) { + db, closeDB, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer closeDB() + catalog := &RegularGachaCatalog{characters: map[uint64]CharacterDesign{}} + for _, quests := range packs { + for _, quest := range quests { + for _, slot := range quest.Rewards { + for _, reward := range slot { + if reward.Type != 11 { + continue + } + if _, exists := catalog.characters[reward.ID]; exists { + continue + } + design, err := loadGachaCharacterDesign(db, reward.ID) + if err != nil { + return nil, err + } + catalog.characters[reward.ID] = design + } + } + } + } + return catalog, nil +} diff --git a/go/internal/server/gamedata/quests.go b/go/internal/server/gamedata/quests.go index 0e87816..4cb940d 100644 --- a/go/internal/server/gamedata/quests.go +++ b/go/internal/server/gamedata/quests.go @@ -13,8 +13,11 @@ import ( // Reward slot zero is normal story difficulty; later slots are separate // difficulty tiers and must never be added to the same clear. type QuestDesign struct { - ID int - Rewards [5][]Reward + ID int + Type int + PriorQuestID int + NextQuestID int + Rewards [5][]Reward } func LoadQuestDesign(root, version string, packID int) (map[int]QuestDesign, error) { @@ -62,6 +65,15 @@ func loadQuestDesignDB(db *sql.DB, packID int) (map[int]QuestDesign, error) { continue } entry := QuestDesign{ID: id} + for field, dst := range map[int]*int{63: &entry.Type, 37: &entry.PriorQuestID, 35: &entry.NextQuestID} { + values, err := packedInts(proto, field) + if err != nil || len(values) > 1 || (len(values) == 1 && values[0] > uint64(^uint32(0)>>1)) { + return nil, fmt.Errorf("gamedata: invalid quest %d field%d", id, field) + } + if len(values) == 1 { + *dst = int(values[0]) + } + } for slot := 0; slot < len(entry.Rewards); slot++ { types, err := packedInts(proto, 56+slot) if err != nil { diff --git a/go/internal/server/gamedata/story_catalog.go b/go/internal/server/gamedata/story_catalog.go new file mode 100644 index 0000000..d809f97 --- /dev/null +++ b/go/internal/server/gamedata/story_catalog.go @@ -0,0 +1,225 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "math" + "sort" +) + +// ContentOpenRule uses group 1 (ContentUnlockPack). The client grants access +// when the ticket exists and squad level is sufficient. TutorialID announces +// the opening; it is not an additional authorization condition. +type ContentOpenRule struct{ SquadLevel, TicketID, TutorialID uint64 } + +type StoryPack struct { + ID, Type, PriorPackID, NextPackID int + BuyType, BuyPrice uint64 + BuyRewards []Reward + MapIDs []int + Open *ContentOpenRule + Quests map[int]QuestDesign + MainQuestIDs []int +} + +type StoryTicketSource struct { + PackID, QuestID, Difficulty int + Count uint64 +} +type StoryCatalog struct { + Packs map[int]StoryPack + TicketSources map[uint64][]StoryTicketSource +} + +// LoadStoryCatalog enumerates all normal story and master packs independently +// of NextPackId. Terminal master packs retain their actual zero next edge. +func LoadStoryCatalog(root, version string) (*StoryCatalog, error) { + db, closeDB, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer closeDB() + return loadStoryCatalog(db) +} + +func loadStoryCatalog(db *sql.DB) (*StoryCatalog, error) { + d := &StoryCatalog{Packs: map[int]StoryPack{}, TicketSources: map[uint64][]StoryTicketSource{}} + rows, err := db.Query("SELECT id,ProtoBuf FROM PackTable ORDER BY id") + if err != nil { + return nil, err + } + for rows.Next() { + var id int + var raw []byte + if err := rows.Scan(&id, &raw); err != nil { + rows.Close() + return nil, err + } + read := func(field int) (int, error) { + values, err := packedInts(raw, field) + if err != nil || len(values) > 1 || (len(values) == 1 && values[0] > math.MaxInt32) { + return 0, fmt.Errorf("gamedata: invalid story pack%d field%d", id, field) + } + if len(values) == 0 { + return 0, nil + } + return int(values[0]), nil + } + kind, err := read(55) + if err != nil { + rows.Close() + return nil, err + } + if kind != 0 && kind != 1000 { + continue + } + protoID, err := read(25) + if err != nil || protoID != id || id <= 0 { + rows.Close() + return nil, fmt.Errorf("gamedata: invalid story pack%d identity", id) + } + pack := StoryPack{ID: id, Type: kind} + buyType, err := read(11) + if err != nil { + rows.Close() + return nil, err + } + pack.BuyType = uint64(buyType) + buyPrice, err := read(7) + if err != nil { + rows.Close() + return nil, err + } + pack.BuyPrice = uint64(buyPrice) + counts, err := packedInts(raw, 8) + if err != nil { + rows.Close() + return nil, err + } + ids, err := packedInts(raw, 9) + if err != nil { + rows.Close() + return nil, err + } + types, err := packedInts(raw, 10) + if err != nil { + rows.Close() + return nil, err + } + if len(counts) != len(ids) || len(ids) != len(types) { + rows.Close() + return nil, fmt.Errorf("gamedata: invalid story pack%d buy rewards", id) + } + for i, count := range counts { + if count == 0 || types[i] == 0 { + rows.Close() + return nil, fmt.Errorf("gamedata: invalid story pack%d buy reward", id) + } + pack.BuyRewards = append(pack.BuyRewards, Reward{Type: types[i], ID: ids[i], Count: count}) + } + if pack.PriorPackID, err = read(57); err != nil { + rows.Close() + return nil, err + } + if pack.NextPackID, err = read(45); err != nil { + rows.Close() + return nil, err + } + maps, err := packedInts(raw, 21) + if err != nil { + rows.Close() + return nil, err + } + for _, mapID := range maps { + if mapID == 0 || mapID > math.MaxInt32 { + rows.Close() + return nil, fmt.Errorf("gamedata: invalid story pack%d map", id) + } + pack.MapIDs = append(pack.MapIDs, int(mapID)) + } + d.Packs[id] = pack + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + rows, err = db.Query("SELECT id,ProtoBuf FROM ContentOpenTable WHERE groupId=1 ORDER BY id") + if err != nil { + return nil, err + } + for rows.Next() { + var id int + var raw []byte + if err := rows.Scan(&id, &raw); err != nil { + rows.Close() + return nil, err + } + pack, found := d.Packs[id] + if !found { + continue + } + var group, protoID uint64 + open := &ContentOpenRule{} + for field, dst := range map[int]*uint64{1: &group, 2: &protoID, 4: &open.TutorialID, 5: &open.SquadLevel, 6: &open.TicketID} { + values, err := packedInts(raw, field) + if err != nil || len(values) > 1 || (len(values) == 1 && values[0] > math.MaxInt32) { + rows.Close() + return nil, fmt.Errorf("gamedata: invalid story contentopen%d", id) + } + if len(values) == 1 { + *dst = values[0] + } + } + if group != 1 || protoID != uint64(id) || open.TicketID == 0 { + rows.Close() + return nil, fmt.Errorf("gamedata: invalid story contentopen%d key/ticket", id) + } + pack.Open = open + d.Packs[id] = pack + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + for id, pack := range d.Packs { + pack.Quests, err = loadQuestDesignDB(db, id) + if err != nil { + return nil, err + } + for questID, quest := range pack.Quests { + if quest.Type == 0 { + pack.MainQuestIDs = append(pack.MainQuestIDs, questID) + } + for slot, rewards := range quest.Rewards { + for _, reward := range rewards { + if reward.Type == 19 { + d.TicketSources[reward.ID] = append(d.TicketSources[reward.ID], StoryTicketSource{PackID: id, QuestID: questID, Difficulty: slot, Count: reward.Count}) + } + } + } + } + sort.Ints(pack.MainQuestIDs) + if len(pack.MainQuestIDs) == 0 { + return nil, fmt.Errorf("gamedata: story pack%d has no main quests", id) + } + d.Packs[id] = pack + } + if len(d.Packs) == 0 { + return nil, fmt.Errorf("gamedata: empty story catalog") + } + for ticket, sources := range d.TicketSources { + sort.Slice(sources, func(i, j int) bool { + if sources[i].PackID != sources[j].PackID { + return sources[i].PackID < sources[j].PackID + } + if sources[i].QuestID != sources[j].QuestID { + return sources[i].QuestID < sources[j].QuestID + } + return sources[i].Difficulty < sources[j].Difficulty + }) + d.TicketSources[ticket] = sources + } + return d, nil +} diff --git a/go/internal/server/gamedata/story_catalog_test.go b/go/internal/server/gamedata/story_catalog_test.go new file mode 100644 index 0000000..84799ff --- /dev/null +++ b/go/internal/server/gamedata/story_catalog_test.go @@ -0,0 +1,86 @@ +package gamedata + +import ( + "bd2server/internal/server/wire" + "database/sql" + "testing" +) + +func TestStoryCatalogEnumeratesSeparateChainsAndContentTicketRules(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + for _, query := range []string{"CREATE TABLE PackTable(id INTEGER PRIMARY KEY,ProtoBuf BLOB)", "CREATE TABLE ContentOpenTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)", "CREATE TABLE QuestTable1(id INTEGER PRIMARY KEY,ProtoBuf BLOB)", "CREATE TABLE QuestTable7(id INTEGER PRIMARY KEY,ProtoBuf BLOB)", "CREATE TABLE QuestTable8(id INTEGER PRIMARY KEY,ProtoBuf BLOB)"} { + if _, err = db.Exec(query); err != nil { + t.Fatal(err) + } + } + for _, pack := range []struct{ id, kind, next int }{{1, 0, 0}, {7, 1000, 8}, {8, 1000, 0}, {9, 2, 0}} { + raw := wire.AppendVarint(nil, 25, uint64(pack.id)) + raw = wire.AppendVarint(raw, 55, uint64(pack.kind)) + raw = wire.AppendVarint(raw, 45, uint64(pack.next)) + raw = wire.AppendVarint(raw, 21, uint64(pack.id*10)) + if pack.id == 7 { + raw = wire.AppendVarint(raw, 8, 1) + raw = wire.AppendVarint(raw, 9, 100) + raw = wire.AppendVarint(raw, 10, 19) + } + if _, err = db.Exec("INSERT INTO PackTable VALUES(?,?)", pack.id, raw); err != nil { + t.Fatal(err) + } + } + raw := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1), 6, 100) + raw = wire.AppendVarint(raw, 5, 3) + raw = wire.AppendVarint(raw, 4, 99) + if _, err = db.Exec("INSERT INTO ContentOpenTable VALUES(1,1,?)", raw); err != nil { + t.Fatal(err) + } + main := wire.AppendVarint(wire.AppendVarint(nil, 35, 2), 37, 0) + if _, err = db.Exec("INSERT INTO QuestTable1 VALUES(1,?)", main); err != nil { + t.Fatal(err) + } + terminal := wire.AppendVarint(nil, 37, 1) + if _, err = db.Exec("INSERT INTO QuestTable1 VALUES(2,?)", terminal); err != nil { + t.Fatal(err) + } + if _, err = db.Exec("INSERT INTO QuestTable1 VALUES(3,?)", wire.AppendVarint(nil, 63, 1)); err != nil { + t.Fatal(err) + } + for _, id := range []int{7, 8} { + query := "INSERT INTO QuestTable7 VALUES(1,?)" + if id == 8 { + query = "INSERT INTO QuestTable8 VALUES(1,?)" + } + raw = wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 46, 1), 51, 101), 56, 19) + if _, err = db.Exec(query, raw); err != nil { + t.Fatal(err) + } + } + d, err := loadStoryCatalog(db) + if err != nil { + t.Fatal(err) + } + if len(d.Packs) != 3 || d.Packs[8].NextPackID != 0 || d.Packs[7].NextPackID != 8 { + t.Fatalf("catalog=%+v", d.Packs) + } + if open := d.Packs[1].Open; open == nil || open.TicketID != 100 || open.SquadLevel != 3 || open.TutorialID != 99 { + t.Fatalf("open=%+v", open) + } + if d.Packs[7].Open != nil { + t.Fatal("invented missing content-open permission rule") + } + if main := d.Packs[1].MainQuestIDs; len(main) != 2 || main[0] != 1 || main[1] != 2 { + t.Fatalf("sidequest in chapter completion=%v", main) + } + if d.Packs[1].Quests[1].NextQuestID != 2 || d.Packs[1].Quests[2].PriorQuestID != 1 || d.Packs[1].Quests[3].Type != 1 { + t.Fatal("quest edges/type were lost") + } + if rewards := d.Packs[7].BuyRewards; len(rewards) != 1 || rewards[0].Type != 19 || rewards[0].ID != 100 { + t.Fatalf("buy rewards=%+v", rewards) + } + if len(d.TicketSources[101]) != 2 { + t.Fatalf("ticket quest sources=%+v", d.TicketSources) + } +} diff --git a/go/internal/server/player/quest_rewards.go b/go/internal/server/player/quest_rewards.go new file mode 100644 index 0000000..08e70e9 --- /dev/null +++ b/go/internal/server/player/quest_rewards.go @@ -0,0 +1,8 @@ +package player + +// CollectionRewardBundle encodes a persisted character/costume grant for +// any reward-bearing response. Its copy upgrades and post-max exchanges are +// identical to recruitment rewards and are replayed from the collection ledger. +func CollectionRewardBundle(c *CollectionStore, g CollectionGrant) []byte { + return recruitRewardBundle(c, g) +} diff --git a/go/internal/server/world/field_pack_test.go b/go/internal/server/world/field_pack_test.go index f36bed5..ddcb1fc 100644 --- a/go/internal/server/world/field_pack_test.go +++ b/go/internal/server/world/field_pack_test.go @@ -127,6 +127,7 @@ func TestLastPlayedStoryPackRestoresItsExactPosition(t *testing.T) { s := testService() 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}} + attachTestStoryCatalog(s) for quest := range s.quests { if err := s.state.ClearQuest(quest, 21); err != nil { t.Fatal(err) diff --git a/go/internal/server/world/health_test.go b/go/internal/server/world/health_test.go index 40eb30f..da47f55 100644 --- a/go/internal/server/world/health_test.go +++ b/go/internal/server/world/health_test.go @@ -41,6 +41,7 @@ func TestTutorialCharInfoRestoresFieldHealthWithoutExposingStoryRoster(t *testin t.Fatal(err) } s := &Service{seed: Seed{PackID: 21, BattleUnlockQuestID: 26}, state: progress.NewStore(), starter: starter, characters: characters} + attachTestStoryCatalog(s) code, response, handled, err := s.Handle("/CharInfo", wire.AppendVarint(nil, 1, 1)) if err != nil || code != 9 || !handled { t.Fatalf("code=%d handled=%v err=%v", code, handled, err) @@ -105,6 +106,7 @@ func TestPackInfoStoryCharactersUsePersistedDeadAndInjuredHP(t *testing.T) { t.Fatal(err) } s := &Service{seed: Seed{PackID: 21, BattleUnlockQuestID: 26, RewardCharacter: all[0], StoryCharacters: all[1:]}, state: state, starter: starter, characters: characters} + attachTestStoryCatalog(s) response, err := s.packInfoFor(21) if err != nil { t.Fatal(err) diff --git a/go/internal/server/world/pack_jam.go b/go/internal/server/world/pack_jam.go new file mode 100644 index 0000000..d6c9271 --- /dev/null +++ b/go/internal/server/world/pack_jam.go @@ -0,0 +1,53 @@ +package world + +import ( + "fmt" + + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" +) + +func packJamIdentity(packID int) string { return fmt.Sprintf("pack-jam:%d", packID) } + +func (s *Service) handlePackDocking(path string, request []byte) (int, []byte, bool, error) { + packID, err := requestPack(request) + if err != nil { + return 0, nil, true, err + } + if _, known := s.questsFor(packID); !known || !s.packUnlocked(packID) { + return 0, nil, true, fmt.Errorf("%w: unavailable docking pack %d", ErrInvalidRequest, packID) + } + if s.wallet == nil || s.packJamDesign == nil { + return 0, nil, true, fmt.Errorf("world: pack jam design or wallet unavailable") + } + identity := packJamIdentity(packID) + if path == "/PackPreviewInfo" { + var response []byte + if active := s.firstUnclearedQuestFor(packID); active != 0 { + quest := wire.AppendVarint(nil, 1, uint64(active)) + quest = wire.AppendVarint(quest, 6, uint64(packID)) + response = wire.AppendBytes(response, 1, quest) + } + if s.wallet.WasGranted(identity) { + response = wire.AppendVarint(response, 3, 1) + } + return 104, response, true, nil + } + // Serialize the claim check and grant so concurrent domain callers cannot + // return the animation reward twice. The wallet commits balance and ledger together. + s.packJamMu.Lock() + defer s.packJamMu.Unlock() + if s.wallet.WasGranted(identity) { + return 72, nil, true, nil + } + reward := s.packJamDesign.Reward + if reward.Type != 3 || reward.ID != 0 || reward.Count == 0 || reward.Count > uint64(^uint32(0)>>1) { + return 0, nil, true, fmt.Errorf("world: unsupported pack jam reward") + } + if _, err := s.wallet.GrantQuestOnce(identity, []gamedata.Reward{reward}); err != nil { + return 0, nil, true, fmt.Errorf("world: grant pack jam reward: %w", err) + } + item := wire.AppendVarint(nil, 3, reward.Type) + item = wire.AppendVarint(item, 4, reward.Count) + return 72, wire.AppendBytes(nil, 1, item), true, nil +} diff --git a/go/internal/server/world/pack_jam_test.go b/go/internal/server/world/pack_jam_test.go new file mode 100644 index 0000000..7bec380 --- /dev/null +++ b/go/internal/server/world/pack_jam_test.go @@ -0,0 +1,105 @@ +package world + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "errors" + "testing" +) + +type jamFailStore struct { + *stateio.Memory + fail bool +} + +func (s *jamFailStore) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error { + if s.fail { + return errors.New("write failed") + } + return s.Memory.SaveWithEntries(domain, core, changes) +} + +func TestPackJamClaimPersistsAndRepeatedDockingSucceeds(t *testing.T) { + storage := &jamFailStore{Memory: stateio.NewMemory()} + wallet, err := player.OpenWallet(storage, player.Currency{}) + if err != nil { + t.Fatal(err) + } + s := testService() + s.wallet = wallet + s.packJamDesign = &gamedata.PackJamDesign{InsertMin: 5, InsertMax: 5, Reward: gamedata.Reward{Type: 3, Count: 150}} + s.fieldPacks = map[int]gamedata.FieldPack{3001: {ID: 3001, Type: 3, MapIDs: map[int]bool{30011: true}}} + request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 3001) + _, preview, _, err := s.Handle("/PackPreviewInfo", request) + if err != nil { + t.Fatal(err) + } + if _, found, _ := wire.Bytes(preview, 1); found { + t.Fatal("arena fabricated story quest") + } + if claimed, _, _ := wire.Varint(preview, 3); claimed != 0 { + t.Fatal("unclaimed reward marked claimed") + } + storage.fail = true + if _, body, _, err := s.Handle("/PackJamEvent", request); err == nil || body != nil { + t.Fatal("failed write returned success reward") + } + if wallet.WasGranted(packJamIdentity(3001)) || wallet.Snapshot().FreeJewelry != 0 { + t.Fatal("failed write changed wallet") + } + _, preview, _, err = s.Handle("/PackPreviewInfo", request) + if claimed, _, _ := wire.Varint(preview, 3); err != nil || claimed != 0 { + t.Fatal("failed grant preview marked claimed") + } + storage.fail = false + code, body, handled, err := s.Handle("/PackJamEvent", request) + if err != nil || !handled || code != 72 { + t.Fatalf("claim: %d %v %v", code, handled, err) + } + item, found, _ := wire.Bytes(body, 1) + typ, _, _ := wire.Varint(item, 3) + count, _, _ := wire.Varint(item, 4) + if !found || typ != 3 || count != 150 || wallet.Snapshot().FreeJewelry != 150 { + t.Fatal("wrong currency reward") + } + s.wallet, err = player.OpenWallet(storage, player.Currency{}) + if err != nil { + t.Fatal(err) + } + _, preview, _, err = s.Handle("/PackPreviewInfo", request) + claimed, _, _ := wire.Varint(preview, 3) + if err != nil || claimed != 1 { + t.Fatal("reopened preview lost claim") + } + code, body, _, err = s.Handle("/PackJamEvent", request) + if err != nil || code != 72 || len(body) != 0 || s.wallet.Snapshot().FreeJewelry != 150 { + t.Fatal("repeated docking replayed reward") + } + story := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21) + _, preview, _, err = s.Handle("/PackPreviewInfo", story) + quest, found, _ := wire.Bytes(preview, 1) + id, _, _ := wire.Varint(quest, 1) + pack, _, _ := wire.Varint(quest, 6) + if err != nil || !found || id != 1 || pack != 21 { + t.Fatal("preview lost real active quest") + } + for _, invalid := range [][]byte{nil, wire.AppendVarint(nil, 2, 3001), wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 9999), wire.AppendVarint(wire.AppendVarint(nil, 1, 0), 2, 3001)} { + for _, path := range []string{"/PackPreviewInfo", "/PackJamEvent"} { + if _, _, handled, err := s.Handle(path, invalid); !handled || !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("invalid %s: %v", path, err) + } + } + } + if s.wallet.Snapshot().FreeJewelry != 150 || s.wallet.WasGranted(packJamIdentity(9999)) { + t.Fatal("invalid pack changed wallet") + } + s.packJamDesign.Reward.Type = 4 + if _, _, _, err := s.Handle("/PackJamEvent", story); err == nil { + t.Fatal("unsupported reward accepted") + } + if s.wallet.WasGranted(packJamIdentity(21)) { + t.Fatal("unsupported reward marked claimed") + } +} diff --git a/go/internal/server/world/pack_purchase.go b/go/internal/server/world/pack_purchase.go new file mode 100644 index 0000000..b4b69d4 --- /dev/null +++ b/go/internal/server/world/pack_purchase.go @@ -0,0 +1,111 @@ +package world + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/wire" + "fmt" +) + +func packPurchaseIdentity(id int) string { return fmt.Sprintf("pack-purchase:%d", id) } + +// EnsureInitialPackPurchase is called only by new-account initialization. It +// awards the bootstrap pack's real purchase rewards, not a recovery inference +// from a saved position or from the former seed-only unlock chain. +func (s *Service) EnsureInitialPackPurchase() error { + _, err := s.purchaseStoryPack(s.seed.PackID, true) + return err +} + +func (s *Service) handlePackBuy(request []byte) (int, []byte, bool, error) { + id, err := requestPack(request) + if err != nil { + return 0, nil, true, err + } + if !s.packUnlocked(id) { + return 0, nil, true, fmt.Errorf("%w: unavailable purchase pack %d", ErrInvalidRequest, id) + } + bundle, err := s.purchaseStoryPack(id, false) + if err != nil { + return 0, nil, true, err + } + var info []byte + for _, row := range s.packDBInfoRows() { + value, _, _ := wire.Varint(row, 1) + if value == uint64(id) { + info = row + break + } + } + if info == nil { + return 0, nil, true, fmt.Errorf("world: purchased pack absent from account") + } + response := wire.AppendBytes(nil, 1, info) + return 6, wire.AppendBytes(response, 2, bundle), true, nil +} + +func (s *Service) grantPurchaseRewards(identity string, rewards []gamedata.Reward) ([]player.Item, error) { + var itemRewards []gamedata.BattleReward + for _, reward := range rewards { + if reward.Count == 0 { + return nil, fmt.Errorf("world: empty pack purchase reward") + } + if reward.Type == 19 { + if reward.ID == 0 { + return nil, fmt.Errorf("world: invalid pack ticket") + } + itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) + } + } + if _, err := s.wallet.GrantQuestOnce(identity+":currency", rewards); err != nil { + return nil, err + } + if len(itemRewards) == 0 { + return nil, nil + } + items, err := s.inventory.GrantOnce(identity+":items", itemRewards) + if err == nil && len(items) == 0 { + items = s.inventory.GrantedItems(identity + ":items") + } + return items, err +} + +func (s *Service) purchaseStoryPack(id int, initial bool) ([]byte, error) { + if s.storyCatalog == nil || s.collection == nil || s.wallet == nil || s.inventory == nil { + return nil, fmt.Errorf("world: purchase services unavailable") + } + pack, exists := s.storyCatalog.Packs[id] + if !exists { + return nil, fmt.Errorf("%w: unknown purchase pack %d", ErrInvalidRequest, id) + } + identity := packPurchaseIdentity(id) + if _, owned := s.collection.Grant(identity); owned { + return []byte{}, nil + } + if !initial && pack.BuyPrice != 0 { + return nil, fmt.Errorf("%w: paid pack purchase type %d is unsupported", ErrInvalidRequest, pack.BuyType) + } + for _, reward := range pack.BuyRewards { + if reward.Type != 3 && reward.Type != 4 && reward.Type != 12 && reward.Type != 19 { + return nil, fmt.Errorf("world: unsupported pack purchase reward type %d", reward.Type) + } + } + items, err := s.grantPurchaseRewards(identity, pack.BuyRewards) + if err != nil { + return nil, err + } + if err := s.collection.RecordGrantMarker(identity); err != nil { + return nil, err + } + var bundle []byte + for _, reward := range pack.BuyRewards { + if reward.Type == 3 || reward.Type == 4 || reward.Type == 12 { + currency := wire.AppendVarint(wire.AppendVarint(nil, 3, reward.Type), 4, reward.Count) + bundle = wire.AppendBytes(bundle, 1, currency) + } + } + for _, item := range items { + bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) + } + return bundle, nil +} diff --git a/go/internal/server/world/pack_purchase_test.go b/go/internal/server/world/pack_purchase_test.go new file mode 100644 index 0000000..226c607 --- /dev/null +++ b/go/internal/server/world/pack_purchase_test.go @@ -0,0 +1,108 @@ +package world + +import ( + "bd2server/internal/server/accountstate" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "errors" + "path/filepath" + "testing" +) + +type purchaseFailStore struct { + *accountstate.Repository + fail bool +} + +func (s *purchaseFailStore) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error { + if s.fail && domain == "collection" { + return errors.New("forced purchase marker failure") + } + return s.Repository.SaveWithEntries(domain, core, changes) +} + +func TestPackPurchaseFailureRollsBackSQLiteCurrencyTicketAndMarker(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + repo, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { repo.Close() }() + storage := &purchaseFailStore{Repository: repo} + s := testService() + s.seed.PackID = 709 + s.inventory, err = player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + s.wallet, err = player.OpenWallet(storage, player.Currency{Catalyst: 5}) + if err != nil { + t.Fatal(err) + } + s.collection, err = player.OpenCollectionStore(storage, nil) + if err != nil { + t.Fatal(err) + } + if _, err := s.wallet.GrantQuestOnce("test:baseline", nil); err != nil { + t.Fatal(err) + } + s.storyCatalog = &gamedata.StoryCatalog{Packs: map[int]gamedata.StoryPack{709: {ID: 709, BuyRewards: []gamedata.Reward{{Type: 12, Count: 17}, {Type: 19, ID: 88, Count: 1}}}}} + op, err := repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + storage.fail = true + request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 709) + if _, _, _, err := s.Handle("/PackBuy", request); err == nil { + t.Fatal("forced collection failure returned success") + } + if err := op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { + t.Fatal(err) + } + if err := repo.Close(); err != nil { + t.Fatal(err) + } + repo, err = accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + storage.Repository = repo + storage.fail = false + // Requests reconstruct domains following a rollback; in-memory snapshots + // that participated in the aborted operation must not be reused. + wallet, err := player.OpenWallet(storage, player.Currency{}) + if err != nil { + t.Fatal(err) + } + inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + collection, err := player.OpenCollectionStore(storage, nil) + if err != nil { + t.Fatal(err) + } + if wallet.CatalystBalance() != 5 || wallet.WasGranted("pack-purchase:709:currency") || len(inventory.All()) != 0 { + t.Fatal("aborted purchase persisted partial awards") + } + if _, found := collection.Grant("pack-purchase:709"); found { + t.Fatal("aborted purchase persisted marker") + } + s.wallet, s.inventory, s.collection = wallet, inventory, collection + op, err = repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + if _, _, _, err := s.Handle("/PackBuy", request); err != nil { + op.Rollback() + t.Fatal(err) + } + if err := op.Commit(); err != nil { + t.Fatal(err) + } + if wallet.CatalystBalance() != 22 || len(inventory.All()) != 1 { + t.Fatal("retried purchase did not grant exactly once") + } +} diff --git a/go/internal/server/world/pack_summary_test.go b/go/internal/server/world/pack_summary_test.go index 0860774..a95f7ef 100644 --- a/go/internal/server/world/pack_summary_test.go +++ b/go/internal/server/world/pack_summary_test.go @@ -6,6 +6,8 @@ import ( "testing" "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" "bd2server/internal/server/wire" ) @@ -14,6 +16,11 @@ func TestPackSummaryUsesUnlockedPackAndCollectedCounts(t *testing.T) { 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}} + attachTestStoryCatalog(s) + pack22 := s.storyCatalog.Packs[22] + pack22.Open = &gamedata.ContentOpenRule{TicketID: 122} + s.storyCatalog.Packs[22] = pack22 + s.inventory, _ = player.OpenInventory(stateio.NewMemory(), s.starter) request := wire.AppendVarint(nil, 1, 7) code, got, handled, err := s.Handle("/PackSummaryInfoList", request) want := wire.AppendBytes(nil, 1, wire.AppendVarint(nil, 1, 21)) @@ -32,6 +39,9 @@ func TestPackSummaryUsesUnlockedPackAndCollectedCounts(t *testing.T) { t.Fatal(err) } } + if _, err := s.inventory.GrantOnce("test-terminal-ticket", []gamedata.BattleReward{{Type: 19, ID: 122, Count: 1}}); 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) { diff --git a/go/internal/server/world/recruit_npc_test.go b/go/internal/server/world/recruit_npc_test.go index 505631f..ce30c9f 100644 --- a/go/internal/server/world/recruit_npc_test.go +++ b/go/internal/server/world/recruit_npc_test.go @@ -52,6 +52,7 @@ func TestResolveRecruitNPCBlocksActiveQuestRangesAfterEarlierRecruitChapter(t *t 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}} + attachTestStoryCatalog(s) for _, id := range []int{1, 2, 3} { if err := s.state.ClearQuest(id, 21); err != nil { t.Fatal(err) diff --git a/go/internal/server/world/story_catalog.go b/go/internal/server/world/story_catalog.go new file mode 100644 index 0000000..27146e5 --- /dev/null +++ b/go/internal/server/world/story_catalog.go @@ -0,0 +1,114 @@ +package world + +import ( + "bd2server/internal/server/wire" + "fmt" + "sort" +) + +// orderMainQuests follows the actual QuestTable links. Subquests never become +// a predecessor merely because their numeric ID lies between two main quests. +func (s *Service) orderMainQuests() error { + for id, pack := range s.storyCatalog.Packs { + start := 0 + for _, qid := range pack.MainQuestIDs { + if pack.Quests[qid].PriorQuestID == 0 { + if start != 0 { + return fmt.Errorf("world: pack %d has multiple main quest roots", id) + } + start = qid + } + } + var ordered []int + seen := map[int]bool{} + for qid := start; qid != 0; { + q, exists := pack.Quests[qid] + if !exists || q.Type != 0 || seen[qid] { + return fmt.Errorf("world: invalid main quest chain pack %d quest %d", id, qid) + } + seen[qid] = true + ordered = append(ordered, qid) + qid = q.NextQuestID + } + if len(ordered) != len(pack.MainQuestIDs) { + return fmt.Errorf("world: disconnected main quest chain pack %d", id) + } + pack.MainQuestIDs = ordered + s.storyCatalog.Packs[id] = pack + } + return nil +} + +func (s *Service) storyPackUnlocked(id int) bool { + pack, exists := s.storyCatalog.Packs[id] + if !exists { + return false + } + // ContentOpen.TutorialID triggers a tutorial; the client does not use it + // as an authorization requirement. Story NextPackID is navigation only. + if pack.Open == nil { + return true + } + if pack.Open.SquadLevel != 0 { + if s.squadLevel == nil { + return false + } + level, err := s.squadLevel() + if err != nil || level < pack.Open.SquadLevel { + return false + } + } + if pack.Open.TicketID != 0 { + if s.inventory == nil { + return false + } + for _, item := range s.inventory.All() { + if item.Type == 19 && item.ID == pack.Open.TicketID && item.Count > 0 { + return true + } + } + return false + } + return true +} + +func (s *Service) storyPackDBInfoRows() [][]byte { + ids := make([]int, 0, len(s.storyCatalog.Packs)) + for id := range s.storyCatalog.Packs { + if s.storyPackUnlocked(id) { + ids = append(ids, id) + } + } + sort.Ints(ids) + var rows [][]byte + for _, id := range ids { + row := wire.AppendVarint(nil, 1, uint64(id)) + mainCleared := 0 + for _, qid := range s.storyCatalog.Packs[id].MainQuestIDs { + if s.state.QuestCleared(qid, id) { + mainCleared++ + } + } + if mainCleared > 0 { + row = wire.AppendVarint(row, 2, uint64(mainCleared)) + } + if s.packCompleteFor(id) { + row = wire.AppendVarint(row, 3, 1) + } + purchased := false + if s.collection != nil { + _, owned := s.collection.Grant(packPurchaseIdentity(id)) + purchased = purchased || owned + } + if purchased { + row = wire.AppendVarint(row, 8, 1) + } + rows = append(rows, row) + } + if saved, found := s.state.Position(); found { + if pack, arena := s.fieldPacks[saved.PackID]; arena && pack.MapIDs[saved.Position.MapID] { + rows = append(rows, wire.AppendVarint(wire.AppendVarint(nil, 1, uint64(saved.PackID)), 8, 1)) + } + } + return rows +} diff --git a/go/internal/server/world/story_catalog_test.go b/go/internal/server/world/story_catalog_test.go new file mode 100644 index 0000000..29ee744 --- /dev/null +++ b/go/internal/server/world/story_catalog_test.go @@ -0,0 +1,104 @@ +package world + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "testing" +) + +func TestStoryCatalogUsesTicketsAndQuestLinksAcrossIndependentPacks(t *testing.T) { + s := testService() + s.seed.PackID = 701 + storage := stateio.NewMemory() + var err error + s.inventory, err = player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + s.wallet, err = player.OpenWallet(storage, player.Currency{}) + if err != nil { + t.Fatal(err) + } + s.collection, err = player.OpenCollectionStore(storage, nil) + if err != nil { + t.Fatal(err) + } + quests := map[int]gamedata.QuestDesign{90: {ID: 90, NextQuestID: 7}, 7: {ID: 7, PriorQuestID: 90, Rewards: [5][]gamedata.Reward{{{Type: 19, ID: 888, Count: 1}}}}, 50: {ID: 50, Type: 1}} + s.storyCatalog = &gamedata.StoryCatalog{Packs: map[int]gamedata.StoryPack{ + 701: {ID: 701, Quests: quests, MainQuestIDs: []int{7, 90}}, + 405: {ID: 405, Open: &gamedata.ContentOpenRule{TicketID: 888}, Quests: map[int]gamedata.QuestDesign{33: {ID: 33}}, MainQuestIDs: []int{33}}, + }} + s.packs = map[int]map[int]gamedata.QuestDesign{701: quests, 405: s.storyCatalog.Packs[405].Quests} + if err := s.orderMainQuests(); err != nil { + t.Fatal(err) + } + if s.firstUnclearedQuestFor(701) != 90 || s.canClear(701, 7) || s.canClear(701, 50) || s.packUnlocked(405) { + t.Fatal("numeric ordering or ticket gate bypassed") + } + if err := s.state.ClearQuest(90, 701); err != nil { + t.Fatal(err) + } + if !s.canClear(701, 7) || s.nextQuestFor(701, 90) != 7 { + t.Fatal("design next/prior ignored") + } + request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 7), 3, 701) + _, response, _, err := s.Handle("/QuestClear", request) + if err != nil { + t.Fatal(err) + } + if !s.packCompleteFor(701) || !s.packUnlocked(405) { + t.Fatal("main terminal ticket did not unlock independent story") + } + if s.state.QuestCleared(50, 701) { + t.Fatal("side quest fabricated") + } + var ids []uint64 + if err := wire.Walk(response, func(f wire.Field) error { + if f.Number == 11 { + id, _, _ := wire.Varint(f.Value, 1) + ids = append(ids, id) + } + return nil + }); err != nil { + t.Fatal(err) + } + if len(ids) != 2 || ids[0] != 405 || ids[1] != 701 { + t.Fatalf("terminal updates %v", ids) + } +} + +func TestInitialPackPurchaseUsesDesignAndDurableIdentity(t *testing.T) { + s := testService() + s.seed.PackID = 707 + storage := stateio.NewMemory() + var err error + s.inventory, err = player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + s.wallet, err = player.OpenWallet(storage, player.Currency{Catalyst: 9}) + if err != nil { + t.Fatal(err) + } + s.collection, err = player.OpenCollectionStore(storage, nil) + if err != nil { + t.Fatal(err) + } + s.storyCatalog = &gamedata.StoryCatalog{Packs: map[int]gamedata.StoryPack{707: {ID: 707, BuyRewards: []gamedata.Reward{{Type: 12, Count: 17}, {Type: 19, ID: 88, Count: 1}}}}} + for i := 0; i < 2; i++ { + if err := s.EnsureInitialPackPurchase(); err != nil { + t.Fatal(err) + } + } + if s.wallet.CatalystBalance() != 26 || len(s.inventory.All()) != 1 { + t.Fatal("purchase reward repeated or hardcoded") + } + if _, owned := s.collection.Grant("pack-purchase:707"); !owned { + t.Fatal("missing purchase marker") + } + if !s.wallet.WasGranted("pack-purchase:707:currency") { + t.Fatal("missing currency marker") + } +} diff --git a/go/internal/server/world/world.go b/go/internal/server/world/world.go index 5c8b8d7..999c6a2 100644 --- a/go/internal/server/world/world.go +++ b/go/internal/server/world/world.go @@ -10,7 +10,6 @@ import ( "fmt" "log/slog" "os" - "sort" "sync" "bd2server/internal/server/deck" @@ -58,7 +57,17 @@ func Load(seedPath, gameDataRoot, gameDataVersion string, storage stateio.Store, if err != nil { return nil, fmt.Errorf("world: open character state: %w", err) } - packs, transitions, err := gamedata.LoadQuestPackChain(gameDataRoot, gameDataVersion, seed.PackID) + storyCatalog, err := gamedata.LoadStoryCatalog(gameDataRoot, gameDataVersion) + if err != nil { + return nil, err + } + packs := make(map[int]map[int]gamedata.QuestDesign) + transitions := make(map[int]gamedata.PackTransition) + for id, pack := range storyCatalog.Packs { + packs[id] = pack.Quests + transitions[id] = gamedata.PackTransition{PackID: id, NextPackID: pack.NextPackID} + } + questCostumes, err := gamedata.LoadQuestCostumes(gameDataRoot, gameDataVersion, packs) if err != nil { return nil, err } @@ -71,6 +80,10 @@ func Load(seedPath, gameDataRoot, gameDataVersion string, storage stateio.Store, if err != nil { return nil, err } + packJamDesign, err := gamedata.LoadPackJamDesign(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) } @@ -84,7 +97,13 @@ 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.packJamDesign = packJamDesign service.packSummaryTargets = packSummaryTargets + service.storyCatalog = storyCatalog + if err := service.orderMainQuests(); err != nil { + return nil, err + } + service.questCostumes = questCostumes service.attachPackDetailDesign(gameDataRoot, gameDataVersion) return service, nil } @@ -119,8 +138,12 @@ func (s *Service) setCurrentPack(packID int) { } type Service struct { + storyCatalog *gamedata.StoryCatalog + questCostumes player.CostumeDesignSource packDetailDesign func(int) (gamedata.PackDetailDesign, error) packSummaryTargets map[int]bool + packJamDesign *gamedata.PackJamDesign + packJamMu sync.Mutex fieldPacks map[int]gamedata.FieldPack squadLevel func() (uint64, error) seed Seed @@ -158,10 +181,14 @@ func (s *Service) AttachDecks(decks *deck.Store) error { func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { switch path { + case "/PackBuy": + return s.handlePackBuy(request) case "/PackDetailInfo": return s.handlePackDetail(request) case "/PackSummaryInfoList": return s.handlePackSummary(request) + case "/PackPreviewInfo", "/PackJamEvent": + return s.handlePackDocking(path, request) case "/PackInfo": seq, found, err := wire.Varint(request, 1) if err != nil || !found || seq == 0 { @@ -286,45 +313,19 @@ func (s *Service) questsFor(packID int) (map[int]gamedata.QuestDesign, bool) { if _, exists := s.fieldPacks[packID]; exists { return map[int]gamedata.QuestDesign{}, true } - if s.packs != nil { - quests, found := s.packs[packID] - return quests, found - } - if packID == s.seed.PackID && s.quests != nil { - return s.quests, true + if s.storyCatalog != nil { + pack, found := s.storyCatalog.Packs[packID] + return pack.Quests, found } return nil, false } -func (s *Service) transitionFor(packID int) gamedata.PackTransition { - if s.transitions != nil { - return s.transitions[packID] - } - if packID == s.seed.PackID { - return s.transition - } - return gamedata.PackTransition{PackID: packID} -} - -// packUnlocked follows the static story chain and requires every preceding -// pack to be complete. This accepts the configured next story pack only after -// its predecessor is complete, without exposing arbitrary GameData tables. +// packUnlocked uses installed ContentOpen rules and real account tickets. func (s *Service) packUnlocked(packID int) bool { if pack, exists := s.fieldPacks[packID]; exists { return s.fieldPackUnlocked(pack) } - current := s.seed.PackID - for steps := 0; steps < 64 && current != 0; steps++ { - if current == packID { - _, found := s.questsFor(current) - return found - } - if !s.packCompleteFor(current) { - return false - } - current = s.transitionFor(current).NextPackID - } - return false + return s.storyCatalog != nil && s.storyPackUnlocked(packID) } func (s *Service) storyCharacters(packID int) []player.Character { @@ -339,27 +340,21 @@ func (s *Service) canClear(packID, quest int) bool { return true } quests, found := s.questsFor(packID) + if !found || s.storyCatalog == nil { + return false + } + design, found := quests[quest] if !found { return false } - startQuestID := 0 - for id := range quests { - if startQuestID == 0 || id < startQuestID { - startQuestID = id - } + if design.PriorQuestID != 0 && !s.state.QuestCleared(design.PriorQuestID, packID) { + return false } - if packID == s.seed.PackID { - startQuestID = s.seed.StartQuestID + if design.Type == 0 { + return quest == s.firstUnclearedQuestFor(packID) } - if quest == startQuestID { - return true - } - for id := range quests { - if id < quest && !s.state.QuestCleared(id, packID) { - return false - } - } - return true + _, active := s.state.QuestInPack(quest, packID) + return active } func (s *Service) grantQuestRewards(packID, quest int, designRewards []gamedata.Reward) ([]player.Item, *player.Equipment, error) { @@ -374,7 +369,7 @@ func (s *Service) grantQuestRewards(packID, quest int, designRewards []gamedata. for i := range designRewards { reward := designRewards[i] switch reward.Type { - case 3, 4: + case 2, 3, 4, 12, 20: if reward.Count == 0 { return nil, nil, errors.New("world: zero currency reward") } @@ -386,8 +381,14 @@ func (s *Service) grantQuestRewards(packID, quest int, designRewards []gamedata. case 11: // Quest 26's character/costume instances come from the versioned // story seed and are encoded below; they are not stackable items. - if packID != s.seed.PackID || quest != s.seed.BattleUnlockQuestID || reward.ID != s.seed.RewardCostume.ID { - return nil, nil, fmt.Errorf("world: unsupported costume reward %d", reward.ID) + if packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && reward.ID == s.seed.RewardCostume.ID { + continue + } + if s.collection == nil || s.questCostumes == nil { + return nil, nil, fmt.Errorf("world: costume reward service unavailable") + } + if _, ok := s.questCostumes.Character(reward.ID); !ok { + return nil, nil, fmt.Errorf("world: missing quest costume %d", reward.ID) } default: if reward.ID == 0 || reward.Count == 0 { @@ -396,6 +397,33 @@ func (s *Service) grantQuestRewards(packID, quest int, designRewards []gamedata. itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) } } + var costumeIDs []uint64 + for _, reward := range designRewards { + if reward.Type == 11 && !(packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && reward.ID == s.seed.RewardCostume.ID) { + costumeIDs = append(costumeIDs, reward.ID) + } + } + if len(costumeIDs) != 0 { + grant, err := s.collection.GrantCostumes(identity+":costumes", costumeIDs, s.questCostumes) + if err != nil { + return nil, nil, fmt.Errorf("world: grant quest costume: %w", err) + } + var exchanges []gamedata.Reward + for _, exchange := range grant.Exchanges { + if exchange.ExchangeItemType != 20 { + return nil, nil, fmt.Errorf("world: unsupported quest costume exchange type %d", exchange.ExchangeItemType) + } + exchanges = append(exchanges, gamedata.Reward{Type: exchange.ExchangeItemType, ID: exchange.ExchangeItemID, Count: exchange.ExchangeCount}) + } + if len(exchanges) > 0 { + if s.wallet == nil { + return nil, nil, fmt.Errorf("world: quest exchange wallet unavailable") + } + if _, err := s.wallet.GrantQuestOnce(identity+":costumes:exchange", exchanges); err != nil { + return nil, nil, err + } + } + } if packID == s.seed.PackID { for _, reward := range questPictorialItems[quest] { itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) @@ -506,16 +534,10 @@ func (s *Service) firstUnclearedQuest() int { } func (s *Service) firstUnclearedQuestFor(packID int) int { - quests, found := s.questsFor(packID) - if !found { + if s.storyCatalog == nil { return 0 } - ids := make([]int, 0, len(quests)) - for id := range quests { - ids = append(ids, id) - } - sort.Ints(ids) - for _, id := range ids { + for _, id := range s.storyCatalog.Packs[packID].MainQuestIDs { if !s.state.QuestCleared(id, packID) { return id } @@ -525,8 +547,13 @@ func (s *Service) firstUnclearedQuestFor(packID int) int { func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Reward, items []player.Item, questEquipment *player.Equipment) []byte { var rewards []byte + if s.collection != nil { + if grant, found := s.collection.Grant(fmt.Sprintf("pack%d:quest%d:costumes", packID, quest)); found { + rewards = append(rewards, player.CollectionRewardBundle(s.collection, grant)...) + } + } for _, reward := range designRewards { - if reward.Type != 3 && reward.Type != 4 { + if reward.Type != 2 && reward.Type != 3 && reward.Type != 4 && reward.Type != 12 && reward.Type != 20 { continue } currency := wire.AppendVarint(nil, 3, reward.Type) @@ -631,11 +658,14 @@ func (s *Service) packComplete() bool { } func (s *Service) packCompleteFor(packID int) bool { - quests, found := s.questsFor(packID) - if !found || len(quests) == 0 { + if s.storyCatalog == nil { return false } - for id := range quests { + ids := s.storyCatalog.Packs[packID].MainQuestIDs + if len(ids) == 0 { + return false + } + for _, id := range ids { if !s.state.QuestCleared(id, packID) { return false } @@ -644,57 +674,10 @@ func (s *Service) packCompleteFor(packID int) bool { } func (s *Service) packDBInfoRows() [][]byte { - var rows [][]byte - packID := s.seed.PackID - for steps := 0; steps < 64 && packID != 0; steps++ { - cleared := s.state.ClearedQuests(packID) - current := wire.AppendVarint(nil, 1, uint64(packID)) - if len(cleared) != 0 { - current = wire.AppendVarint(current, 2, uint64(len(cleared))) - } - complete := s.packCompleteFor(packID) - if complete { - current = wire.AppendVarint(current, 3, 1) - } - // The seed pack was purchased by the account bootstrap. A later pack is - // considered entered once it has its own progress or saved position; - // the newly unlocked, untouched row intentionally remains unpurchased. - purchased := packID == s.seed.PackID || len(cleared) != 0 - if saved, found := s.state.Position(); found && saved.PackID == packID { - purchased = true - } - if purchased { - current = wire.AppendVarint(current, 8, 1) - } - rows = append(rows, current) - if !complete { - break - } - nextPackID := s.transitionFor(packID).NextPackID - if nextPackID == 0 { - break - } - if _, found := s.questsFor(nextPackID); !found { - // Unit-sized services and partial deployments may know the real - // transition before the next QuestTable is attached. Preserve the - // protocol's unlocked row, but packUnlocked still refuses entry until - // that table is actually available. - rows = append(rows, wire.AppendVarint(nil, 1, uint64(nextPackID))) - break - } - packID = nextPackID + if s.storyCatalog == nil { + return nil } - // A committed arena position is this account's prior-entry marker. Expose - // only that arena as purchased so the client restores its existing lobby - // without treating every independent arena as unlocked or rewarded. - if saved, found := s.state.Position(); found { - if pack, arena := s.fieldPacks[saved.PackID]; arena && pack.MapIDs[saved.Position.MapID] { - current := wire.AppendVarint(nil, 1, uint64(saved.PackID)) - current = wire.AppendVarint(current, 8, 1) - rows = append(rows, current) - } - } - return rows + return s.storyPackDBInfoRows() } func (s *Service) accountPackInfo() []byte { @@ -771,17 +754,7 @@ func (s *Service) nextQuestFor(packID, current int) int { if !found { return 0 } - ids := make([]int, 0, len(quests)) - for id := range quests { - if id > current { - ids = append(ids, id) - } - } - sort.Ints(ids) - if len(ids) == 0 { - return 0 - } - return ids[0] + return quests[current].NextQuestID } func encodeCharacter(c player.Character) []byte { diff --git a/go/internal/server/world/world_test.go b/go/internal/server/world/world_test.go index e1d3c3b..2e9cc47 100644 --- a/go/internal/server/world/world_test.go +++ b/go/internal/server/world/world_test.go @@ -3,6 +3,7 @@ package world import ( "bytes" "encoding/binary" + "sort" "testing" "bd2server/internal/server/deck" @@ -13,8 +14,44 @@ import ( "bd2server/internal/server/wire" ) +// Legacy regression fixtures define their own linear IDs. Production uses +// GameData links; this helper gives those small test fixtures explicit links. +func attachTestStoryCatalog(s *Service) { + packs := s.packs + if packs == nil { + packs = map[int]map[int]gamedata.QuestDesign{s.seed.PackID: s.quests} + } + s.storyCatalog = &gamedata.StoryCatalog{Packs: map[int]gamedata.StoryPack{}} + for id, quests := range packs { + var ids []int + for qid := range quests { + ids = append(ids, qid) + } + sort.Ints(ids) + for i, qid := range ids { + q := quests[qid] + q.Type = 0 + if i > 0 { + q.PriorQuestID = ids[i-1] + } + if i+1 < len(ids) { + q.NextQuestID = ids[i+1] + } + quests[qid] = q + } + s.storyCatalog.Packs[id] = gamedata.StoryPack{ID: id, Quests: quests, MainQuestIDs: ids} + } + if s.transition.NextPackID != 0 { + if _, exists := s.storyCatalog.Packs[s.transition.NextPackID]; !exists { + s.storyCatalog.Packs[s.transition.NextPackID] = gamedata.StoryPack{ID: s.transition.NextPackID} + } + } +} + func testService() *Service { - return &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, state: progress.NewStore(), starter: &player.Starter{Version: "2.35.10"}, quests: map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}, 3: {ID: 3}}} + s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, state: progress.NewStore(), starter: &player.Starter{Version: "2.35.10"}, quests: map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}, 3: {ID: 3}}} + attachTestStoryCatalog(s) + return s } func TestQuest29EchoesCurrentStoryDeck(t *testing.T) { @@ -47,6 +84,7 @@ func TestQuest29EchoesCurrentStoryDeck(t *testing.T) { } s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, state: state, starter: &player.Starter{Version: "2.35.10"}, quests: quests, decks: decks} + attachTestStoryCatalog(s) request := wire.AppendVarint(nil, 1, 2) request = wire.AppendVarint(request, 2, 29) request = wire.AppendVarint(request, 3, 21) @@ -164,6 +202,7 @@ func TestFinalQuestClearIncludesEmptyNextQuestInfo(t *testing.T) { quests: map[int]gamedata.QuestDesign{38: {ID: 38}}, transition: gamedata.PackTransition{PackID: 21, NextPackID: 22}, } + attachTestStoryCatalog(s) request := wire.AppendVarint(nil, 1, 1) request = wire.AppendVarint(request, 2, 38) request = wire.AppendVarint(request, 3, 21) @@ -208,6 +247,7 @@ func TestPackInfoRestoresCompletedPackAndUnlockedNextPack(t *testing.T) { } s := &Service{seed: Seed{Version: "2.35.10", PackID: 21}, state: state, quests: map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}}, transition: gamedata.PackTransition{PackID: 21, NextPackID: 22}} + attachTestStoryCatalog(s) code, response, handled, err := s.Handle("/PackInfo", wire.AppendVarint(nil, 1, 1)) if err != nil || !handled || code != 4 { t.Fatalf("PackInfo code=%d handled=%v err=%v", code, handled, err) @@ -248,6 +288,7 @@ func TestPack22InitializesWithIndependentQuestIdentity(t *testing.T) { 22: {PackID: 22}, }, } + attachTestStoryCatalog(s) request := wire.AppendVarint(nil, 1, 1) request = wire.AppendVarint(request, 2, 22) code, response, handled, err := s.Handle("/PackInGameInfo", request) @@ -312,6 +353,7 @@ func TestQuest28GrantsEquipmentInRewardBundle(t *testing.T) { quests[28] = entry s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, state: state, starter: starter, equipment: equipment, inventory: inventory, quests: quests} + attachTestStoryCatalog(s) request := wire.AppendVarint(nil, 1, 1) request = wire.AppendVarint(request, 2, 28) request = wire.AppendVarint(request, 3, 21) @@ -358,6 +400,7 @@ func TestQuest27UsesGameDataFreeJewelryReward(t *testing.T) { } s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, state: state, starter: &player.Starter{Version: "2.35.10"}, wallet: wallet, quests: quests} + attachTestStoryCatalog(s) request := wire.AppendVarint(nil, 1, 1) request = wire.AppendVarint(request, 2, 27) request = wire.AppendVarint(request, 3, 21) @@ -394,6 +437,7 @@ func TestPackInfoUsesPersistedCharacterLevel(t *testing.T) { t.Fatal(err) } s := &Service{seed: Seed{PackID: 21, BattleUnlockQuestID: 26, RewardCharacter: player.Character{InvenIndex: 77, ID: 350, Level: 1}}, state: state, starter: starter, characters: characters} + attachTestStoryCatalog(s) response, err := s.packInfo() if err != nil { t.Fatal(err)