fix(server): load story chapters and unlock rewards from GameData

This commit is contained in:
2026-10-04 14:55:25 +08:00
parent 3825ee7e60
commit 013c4dcf1c
23 changed files with 1215 additions and 194 deletions
+1 -1
View File
@@ -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)
}
-1
View File
@@ -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.
+61
View File
@@ -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
}
@@ -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")
}
}
@@ -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 {
@@ -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
}
+14 -2
View File
@@ -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 {
@@ -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
}
@@ -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)
}
}
@@ -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)
}
@@ -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)
+2
View File
@@ -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)
+53
View File
@@ -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
}
+105
View File
@@ -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")
}
}
+111
View File
@@ -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
}
@@ -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")
}
}
@@ -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) {
@@ -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)
+114
View File
@@ -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
}
@@ -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")
}
}
+98 -125
View File
@@ -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 {
+45 -1
View File
@@ -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)