feat(all): add hunting and events, commerce and rewards, binary calendars, GameData caching and login diagnostics

Implement hunting AP and progress, event tasks, exchanges, games, stories and actions; connect battle and reward handling.

Add CashShop plugin and installation, server purchases and entitlements, recharge rules and plugin version metadata.

Distribute binary calendars independently; cache GameData in memory and fix single-connection query deadlocks.

Improve automatic login and recovery diagnostics; document configuration and release flows.
This commit is contained in:
2026-10-05 11:50:31 +08:00
parent eb445f88f4
commit d8771caef2
222 changed files with 19000 additions and 4284 deletions
+2 -18
View File
@@ -1,11 +1,8 @@
package gamedata
import (
"database/sql"
"encoding/binary"
"fmt"
"os"
"path/filepath"
"bd2server/internal/server/wire"
_ "modernc.org/sqlite"
@@ -38,24 +35,11 @@ func BattleRewards(root, version string, packID, monsterID int) ([]BattleReward,
}
func readBattleRewards(root, version string, packID, deckID, monsterID int) ([]BattleReward, error) {
plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID))
db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", packID))
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-battle-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "pack.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
if monsterID != 0 {
var monster []byte
if err := db.QueryRow("SELECT ProtoBuf FROM FieldMonsterTable WHERE id=?", monsterID).Scan(&monster); err != nil {
@@ -3,8 +3,6 @@ package gamedata
import (
"database/sql"
"fmt"
"os"
"path/filepath"
)
// BattleDeckForDifficulty mirrors QuicklyQuestHelper.GetBattleDeckIdByDifficulty
@@ -23,25 +21,7 @@ func openPackDatabase(root, version string, packID int) (*sql.DB, func(), error)
if packID <= 0 {
return nil, nil, fmt.Errorf("gamedata: invalid battle pack %d", packID)
}
plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID))
if err != nil {
return nil, nil, err
}
dir, err := os.MkdirTemp("", "bd2-difficulty-")
if err != nil {
return nil, nil, err
}
path := filepath.Join(dir, "pack.db")
if err := os.WriteFile(path, plain, 0600); err != nil {
os.RemoveAll(dir)
return nil, nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
os.RemoveAll(dir)
return nil, nil, err
}
return db, func() { db.Close(); os.RemoveAll(dir) }, nil
return OpenDatabase(root, version, fmt.Sprintf("pack%d", packID))
}
func battleDeckForDifficultyFromDB(db *sql.DB, deckID, difficulty uint64) (uint64, error) {
+2 -17
View File
@@ -3,8 +3,6 @@ package gamedata
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"bd2server/internal/server/wire"
)
@@ -19,24 +17,11 @@ func BattleDeckPhases(root, version string, packID int, monsterID, deckID uint64
if packID <= 0 || monsterID == 0 || deckID == 0 || monsterID > 2147483647 || deckID > 2147483647 {
return nil, fmt.Errorf("gamedata: invalid phase battle pack/monster/deck %d/%d/%d", packID, monsterID, deckID)
}
plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID))
db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", packID))
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-phase-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "pack.db")
if err := os.WriteFile(path, plain, 0600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
return battleDeckPhasesFromDB(db, monsterID, deckID)
}
@@ -0,0 +1,39 @@
package gamedata
import "fmt"
// LoadMonsterHuntIDs reads only supported hunt identities. Historical calendar
// entries remain display metadata even after their gameplay design is removed.
func LoadMonsterHuntIDs(root, version string) (map[uint64]bool, error) {
return loadCalendarIDs(root, version, "MonsterHuntTable")
}
// LoadCalendarPackIDs includes story and event packs, rather than only the
// arena subset returned by LoadFieldPacks.
func LoadCalendarPackIDs(root, version string) (map[uint64]bool, error) {
return loadCalendarIDs(root, version, "PackTable")
}
func loadCalendarIDs(root, version, table string) (map[uint64]bool, error) {
db, done, e := openStatDatabase(root, version)
if e != nil {
return nil, e
}
defer done()
rows, e := db.Query("SELECT id FROM " + table)
if e != nil {
return nil, e
}
defer rows.Close()
ids := map[uint64]bool{}
for rows.Next() {
var id uint64
if e = rows.Scan(&id); e != nil {
return nil, e
}
if id == 0 {
return nil, fmt.Errorf("gamedata: invalid monster hunt identity")
}
ids[id] = true
}
return ids, rows.Err()
}
@@ -0,0 +1,91 @@
package gamedata
import (
"database/sql"
"fmt"
"sort"
)
type CashAttendanceReward struct {
ID uint64
Reward BattleReward
}
type CashEntitlementDesign struct {
AvatarSets map[uint64]bool
TicketTypes map[uint64]uint64
Attendance map[uint64][]CashAttendanceReward
AttendanceTypes map[uint64]uint64
}
func LoadCashEntitlementDesign(root, version string) (*CashEntitlementDesign, error) {
db, done, e := openStatDatabase(root, version)
if e != nil {
return nil, e
}
defer done()
return loadCashEntitlementDesign(db)
}
func loadCashEntitlementDesign(db *sql.DB) (*CashEntitlementDesign, error) {
d := &CashEntitlementDesign{AvatarSets: map[uint64]bool{}, TicketTypes: map[uint64]uint64{}, Attendance: map[uint64][]CashAttendanceReward{}, AttendanceTypes: map[uint64]uint64{}}
if e := readCashMetadata(db, "AvatarSetTable", func(raw []byte) error {
v, e := cashScalars(raw, 6)
if e != nil {
return e
}
if v[0] == 0 {
return fmt.Errorf("gamedata: invalid avatar set")
}
d.AvatarSets[v[0]] = true
return nil
}); e != nil {
return nil, e
}
if e := readCashMetadata(db, "ContentTicketTable", func(raw []byte) error {
v, e := cashScalars(raw, 4, 8)
if e != nil {
return e
}
d.TicketTypes[v[0]] = v[1]
return nil
}); e != nil {
return nil, e
}
if e := readCashMetadata(db, "ContentOpenTable", func(raw []byte) error {
v, e := cashScalars(raw, 6, 2)
if e != nil {
return e
}
if d.TicketTypes[v[0]] == 2 {
d.AttendanceTypes[v[0]] = v[1]
}
return nil
}); e != nil {
return nil, e
}
if e := readCashMetadata(db, "AttendanceRewardTable", func(raw []byte) error {
v, e := cashScalars(raw, 1, 2, 5, 4, 3)
if e != nil {
return e
}
if v[0] == 0 || v[1] == 0 || v[2] == 0 || v[4] == 0 {
return fmt.Errorf("gamedata: invalid cash attendance reward")
}
d.Attendance[v[0]] = append(d.Attendance[v[0]], CashAttendanceReward{v[1], BattleReward{Type: v[2], ID: v[3], Count: v[4]}})
return nil
}); e != nil {
return nil, e
}
for ticket, rows := range d.Attendance {
sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID })
for i, r := range rows {
if r.ID != uint64(i+1) {
return nil, fmt.Errorf("gamedata: non-contiguous cash attendance %d", ticket)
}
}
if d.TicketTypes[ticket] != 2 && d.TicketTypes[ticket] != 3 {
return nil, fmt.Errorf("gamedata: missing cash attendance ticket %d", ticket)
}
d.Attendance[ticket] = rows
}
return d, nil
}
@@ -0,0 +1,38 @@
package gamedata
import (
"bd2server/internal/server/wire"
"database/sql"
"testing"
)
func TestCashEntitlementDesignUsesTicketTypesAndAttendanceMapping(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
for _, table := range []string{"AvatarSetTable", "ContentTicketTable", "ContentOpenTable", "AttendanceRewardTable"} {
if _, err = db.Exec("CREATE TABLE " + table + "(ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
}
db.Exec("INSERT INTO AvatarSetTable VALUES(?)", wire.AppendVarint(nil, 6, 10))
raw := wire.AppendVarint(nil, 4, 38)
raw = wire.AppendVarint(raw, 8, 2)
db.Exec("INSERT INTO ContentTicketTable VALUES(?)", raw)
raw = wire.AppendVarint(nil, 6, 38)
raw = wire.AppendVarint(raw, 2, 1)
db.Exec("INSERT INTO ContentOpenTable VALUES(?)", raw)
for _, id := range []uint64{2, 1} {
raw = wire.AppendVarint(nil, 1, 38)
raw = wire.AppendVarint(raw, 2, id)
raw = wire.AppendVarint(raw, 3, 60)
raw = wire.AppendVarint(raw, 5, 3)
db.Exec("INSERT INTO AttendanceRewardTable VALUES(?)", raw)
}
d, err := loadCashEntitlementDesign(db)
if err != nil || !d.AvatarSets[10] || d.TicketTypes[38] != 2 || d.AttendanceTypes[38] != 1 || len(d.Attendance[38]) != 2 || d.Attendance[38][0].ID != 1 {
t.Fatalf("%+v %v", d, err)
}
}
@@ -0,0 +1,217 @@
package gamedata
import (
"database/sql"
"fmt"
"math"
"bd2server/internal/server/wire"
)
// CashProductKey is the official three-part identity; an SDK SKU can be shared
// by several product variants and must not replace this key.
type CashProductKey struct{ GroupID, ProductID, SaleGroup uint64 }
type CashProductDesign struct {
Key CashProductKey
GoogleSKU, AppleSKU string
PriceType, PriceID, PriceCount uint64
RandomBoxID, BonusRandomBoxID uint64
PurchaseLimitType, PurchaseLimitCount, TimeLimitType, BulkOrderAvailability uint64
LocalTextID uint64
Recharge bool
NominalPaidDiamonds uint64
}
type CashShopDesign struct{ GroupID, ID, ProductGroupID, BulkOrderShow uint64 }
type CashPackageDesign struct{ GroupID, ID, SaleGroup, PackageType, PaidShopGroupID, PaidShopID, ContentsGroupID, ContentsSortID uint64 }
type CashCatalog struct {
Products []CashProductDesign
Shops []CashShopDesign
Packages []CashPackageDesign
}
func LoadCashCatalog(root, version string) (*CashCatalog, error) {
db, done, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer done()
return loadCashCatalog(db)
}
func loadCashCatalog(db *sql.DB) (*CashCatalog, error) {
c := &CashCatalog{}
rows, err := db.Query("SELECT ProtoBuf FROM CashProductTable ORDER BY groupId,id,saleGroup")
if err != nil {
return nil, err
}
for rows.Next() {
var raw []byte
if err = rows.Scan(&raw); err != nil {
rows.Close()
return nil, err
}
p, e := decodeCashProduct(raw)
if e != nil {
rows.Close()
return nil, e
}
c.Products = append(c.Products, p)
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
seen := map[CashProductKey]bool{}
// Direct single paid-diamond rewards identify recharge offers. The least
// grant among variants of the same group/sale tier is the regular amount,
// so first-purchase doubling never doubles the configurable price.
nominal := map[[2]uint64]uint64{}
for i := range c.Products {
p := &c.Products[i]
if seen[p.Key] {
return nil, fmt.Errorf("gamedata: duplicate cash product %+v", p.Key)
}
seen[p.Key] = true
if p.PriceType != 1 || p.RandomBoxID == 0 {
continue
}
n, e := cashPaidDiamondReward(db, p.RandomBoxID)
if e != nil {
return nil, fmt.Errorf("gamedata: cash product %+v: %w", p.Key, e)
}
if n == 0 {
continue
}
p.Recharge = true
k := [2]uint64{p.Key.GroupID, p.Key.SaleGroup}
if nominal[k] == 0 || n < nominal[k] {
nominal[k] = n
}
}
for i := range c.Products {
p := &c.Products[i]
if p.Recharge {
p.NominalPaidDiamonds = nominal[[2]uint64{p.Key.GroupID, p.Key.SaleGroup}]
}
}
if err = readCashMetadata(db, "CashShopTable", func(raw []byte) error {
v, e := cashScalars(raw, 4, 5, 10, 2)
if e != nil {
return e
}
c.Shops = append(c.Shops, CashShopDesign{v[0], v[1], v[2], v[3]})
return nil
}); err != nil {
return nil, err
}
if err = readCashMetadata(db, "CashPackageTable", func(raw []byte) error {
v, e := cashScalars(raw, 8, 9, 17, 11, 12, 13, 4, 7)
if e != nil {
return e
}
c.Packages = append(c.Packages, CashPackageDesign{v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7]})
return nil
}); err != nil {
return nil, err
}
return c, nil
}
func readCashMetadata(db *sql.DB, table string, visit func([]byte) error) error {
rows, err := db.Query("SELECT ProtoBuf FROM " + table)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var raw []byte
if err = rows.Scan(&raw); err != nil {
return err
}
if err = visit(raw); err != nil {
return err
}
}
return rows.Err()
}
func cashScalars(raw []byte, fields ...int) ([]uint64, error) {
out := make([]uint64, len(fields))
for i, f := range fields {
v, e := optionalScalar(raw, f)
if e != nil {
return nil, e
}
if v > math.MaxInt32 {
return nil, fmt.Errorf("gamedata: cash field %d outside int32", f)
}
out[i] = v
}
return out, nil
}
func decodeCashProduct(raw []byte) (CashProductDesign, error) {
v, e := cashScalars(raw, 5, 6, 15, 9, 8, 7, 14, 2, 13, 12, 16, 3, 11)
if e != nil {
return CashProductDesign{}, e
}
google, _, e := wire.Bytes(raw, 4)
if e != nil {
return CashProductDesign{}, e
}
apple, _, e := wire.Bytes(raw, 1)
if e != nil {
return CashProductDesign{}, e
}
p := CashProductDesign{Key: CashProductKey{v[0], v[1], v[2]}, GoogleSKU: string(google), AppleSKU: string(apple), PriceType: v[3], PriceID: v[4], PriceCount: v[5], RandomBoxID: v[6], BonusRandomBoxID: v[7], PurchaseLimitType: v[8], PurchaseLimitCount: v[9], TimeLimitType: v[10], BulkOrderAvailability: v[11], LocalTextID: v[12]}
if p.Key.GroupID == 0 || p.Key.ProductID == 0 {
return p, fmt.Errorf("gamedata: invalid cash product key %+v", p.Key)
}
if p.PriceType == 1 && (p.GoogleSKU == "" && p.AppleSKU == "" || p.PriceCount == 0) {
return p, fmt.Errorf("gamedata: cash product lacks SKU/price %+v", p.Key)
}
return p, nil
}
// Mixed packages, random selection, nested boxes and non-diamond leaves are
// ordinary goods. Only the audited pure direct paid-diamond shape qualifies.
func cashPaidDiamondReward(db *sql.DB, box uint64) (uint64, error) {
var raw []byte
if err := db.QueryRow("SELECT ProtoBuf FROM RandomBoxTable WHERE id=?", box).Scan(&raw); err != nil {
return 0, err
}
gid, e := optionalScalar(raw, 9)
if e != nil {
return 0, e
}
if gid == 0 {
return 0, nil
}
if e = db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", gid).Scan(&raw); e != nil {
return 0, e
}
drop, e := optionalScalar(raw, 2)
if e != nil {
return 0, e
}
types, e := packedInts(raw, 6)
if e != nil {
return 0, e
}
ids, e := packedInts(raw, 5)
if e != nil {
return 0, e
}
counts, e := packedInts(raw, 4)
if e != nil {
return 0, e
}
ratios, e := packedInts(raw, 8)
if e != nil {
return 0, e
}
if drop != 1 || len(types) != 1 || len(ids) != 1 || len(counts) != 1 || len(ratios) != 1 || types[0] != 2 || ids[0] != 0 || ratios[0] != 100 {
return 0, nil
}
if counts[0] == 0 || counts[0] > math.MaxInt32 {
return 0, fmt.Errorf("gamedata: invalid paid-diamond recharge amount")
}
return counts[0], nil
}
@@ -0,0 +1,77 @@
package gamedata
import (
"bd2server/internal/server/wire"
"database/sql"
"testing"
)
func TestCashCatalogPreservesKeysAliasesAndLimits(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.SetMaxOpenConns(1)
for _, q := range []string{"CREATE TABLE CashProductTable(groupId INTEGER,id INTEGER,saleGroup INTEGER,ProtoBuf BLOB)", "CREATE TABLE RandomBoxTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE RewardGroupTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE CashShopTable(ProtoBuf BLOB)", "CREATE TABLE CashPackageTable(ProtoBuf BLOB)"} {
if _, err = db.Exec(q); err != nil {
t.Fatal(err)
}
}
for i, count := range []uint64{200, 100} {
raw := wire.AppendVarint(nil, 2, 1)
raw = wire.AppendVarint(raw, 6, 2)
raw = wire.AppendVarint(raw, 5, 0)
raw = wire.AppendVarint(raw, 4, count)
raw = wire.AppendVarint(raw, 8, 100)
if _, err = db.Exec("INSERT INTO RewardGroupTable VALUES(?,?)", i+1, raw); err != nil {
t.Fatal(err)
}
if _, err = db.Exec("INSERT INTO RandomBoxTable VALUES(?,?)", i+1, wire.AppendVarint(nil, 9, uint64(i+1))); err != nil {
t.Fatal(err)
}
raw = wire.AppendString(nil, 4, "shared-sku")
raw = wire.AppendString(raw, 1, "shared-ios")
for _, f := range []struct {
n int
v uint64
}{{5, 99}, {6, uint64(i + 1)}, {15, 7}, {9, 1}, {7, 2200}, {14, uint64(i + 1)}, {2, 333}, {12, 1}, {13, 3}, {16, 2}, {3, 1}} {
raw = wire.AppendVarint(raw, f.n, f.v)
}
if _, err = db.Exec("INSERT INTO CashProductTable VALUES(99,?,7,?)", i+1, raw); err != nil {
t.Fatal(err)
}
}
shop := wire.AppendVarint(nil, 4, 8)
shop = wire.AppendVarint(shop, 5, 1)
shop = wire.AppendVarint(shop, 10, 99)
if _, err = db.Exec("INSERT INTO CashShopTable VALUES(?)", shop); err != nil {
t.Fatal(err)
}
packageRaw := wire.AppendVarint(nil, 8, 99)
packageRaw = wire.AppendVarint(packageRaw, 9, 1)
packageRaw = wire.AppendVarint(packageRaw, 17, 7)
packageRaw = wire.AppendVarint(packageRaw, 11, 12)
if _, err = db.Exec("INSERT INTO CashPackageTable VALUES(?)", packageRaw); err != nil {
t.Fatal(err)
}
c, err := loadCashCatalog(db)
if err != nil {
t.Fatal(err)
}
if len(c.Products) != 2 || len(c.Shops) != 1 || len(c.Packages) != 1 {
t.Fatalf("incomplete %+v", c)
}
for _, p := range c.Products {
if !p.Recharge || p.NominalPaidDiamonds != 100 || p.GoogleSKU != "shared-sku" || p.AppleSKU != "shared-ios" || p.BonusRandomBoxID != 333 || p.PurchaseLimitType != 3 || p.TimeLimitType != 2 {
t.Fatalf("lost metadata %+v", p)
}
}
}
func TestDecodeCashProductRejectsMalformedMonetaryFields(t *testing.T) {
for _, raw := range [][]byte{{255}, wire.AppendVarint(nil, 5, 1), wire.AppendVarint(wire.AppendVarint(nil, 5, 1), 6, 1<<40)} {
if _, err := decodeCashProduct(raw); err == nil {
t.Fatalf("accepted %x", raw)
}
}
}
+103
View File
@@ -0,0 +1,103 @@
package gamedata
import (
"fmt"
"math"
)
// CashRewardResolver gives cash combination groups their storefront meaning:
// ratio 1 and ratio 100 both describe guaranteed components. Weighted groups
// keep the shared reward graph sampler, and manually opened boxes stay items.
type CashRewardResolver struct {
graph *RewardGraph
boxes map[uint64]uint64
direct map[uint64]bool
groups map[uint64][]byte
}
func LoadCashRewardResolver(root, version string, shared ...*RewardGraph) (*CashRewardResolver, error) {
var graph *RewardGraph
if len(shared) > 0 {
graph = shared[0]
}
if graph == nil {
var err error
graph, err = LoadRewardGraph(root, version)
if err != nil {
return nil, err
}
}
// The shared immutable design maps are initialized completely at startup.
return &CashRewardResolver{graph: graph, boxes: graph.boxes, direct: graph.direct, groups: graph.groups}, nil
}
func (c *CashRewardResolver) ResolveGranted(rewards []BattleReward) ([]BattleReward, error) {
budget := uint64(100000)
visiting := map[uint64]bool{}
var out []BattleReward
var emit func(BattleReward, bool) error
emit = func(r BattleReward, force bool) error {
if budget == 0 || r.Count == 0 || r.Count > math.MaxInt32 || r.Type == 0 {
return fmt.Errorf("gamedata: invalid cash reward/budget")
}
budget--
if r.Type != 9 || !force && !c.direct[r.ID] {
out = append(out, r)
return nil
}
if visiting[r.ID] || len(visiting) >= 32 {
return fmt.Errorf("gamedata: cash reward cycle")
}
gid, ok := c.boxes[r.ID]
if !ok {
return fmt.Errorf("gamedata: unknown cash random box %d", r.ID)
}
raw, ok := c.groups[gid]
if !ok {
return fmt.Errorf("gamedata: unknown cash reward group %d", gid)
}
drop, err := optionalScalar(raw, 2)
if err != nil {
return err
}
if drop == 0 {
selected, err := c.graph.Resolve([]BattleReward{r})
if err != nil {
return err
}
out = append(out, selected...)
return nil
}
if drop != 1 {
return fmt.Errorf("gamedata: unsupported cash reward mode %d", drop)
}
children, err := eventGameRewards(raw, 6, 5, 4)
if err != nil || len(children) == 0 {
return fmt.Errorf("gamedata: malformed cash combination")
}
weights, err := packedInts(raw, 8)
if err != nil || len(weights) != len(children) {
return fmt.Errorf("gamedata: malformed cash combination ratios")
}
visiting[r.ID] = true
defer delete(visiting, r.ID)
for i, child := range children {
if weights[i] != 1 && weights[i] != 100 {
return fmt.Errorf("gamedata: cash combination ratio %d is not guaranteed", weights[i])
}
if child.Count > math.MaxInt32/r.Count {
return fmt.Errorf("gamedata: cash reward overflow")
}
child.Count *= r.Count
if err = emit(child, false); err != nil {
return err
}
}
return nil
}
for _, r := range rewards {
if err := emit(r, true); err != nil {
return nil, err
}
}
return out, nil
}
@@ -0,0 +1,41 @@
package gamedata
import (
"bd2server/internal/server/wire"
"testing"
)
func TestCashCombinationsGuaranteeOneAndHundredPreserveManualBoxes(t *testing.T) {
raw := wire.AppendVarint(nil, 2, 1)
for _, f := range []struct {
n int
v uint64
}{{6, 3}, {6, 8}, {5, 0}, {5, 1000}, {4, 60}, {4, 2}, {8, 1}, {8, 100}} {
raw = wire.AppendVarint(raw, f.n, f.v)
}
c := &CashRewardResolver{boxes: map[uint64]uint64{1: 10}, direct: map[uint64]bool{1: true}, groups: map[uint64][]byte{10: raw}}
got, err := c.ResolveGranted([]BattleReward{{Type: 9, ID: 1, Count: 2}, {Type: 8, ID: 2, Count: 1}})
if err != nil || len(got) != 3 || got[0].Type != 3 || got[0].Count != 120 || got[1].Count != 4 || got[2].ID != 2 || got[2].Type != 8 {
t.Fatalf("cash rewards %+v %v", got, err)
}
raw = wire.AppendVarint(raw, 8, 35)
c.groups[10] = raw
if _, err = c.ResolveGranted([]BattleReward{{Type: 9, ID: 1, Count: 1}}); err == nil {
t.Fatal("malformed ratio accepted")
}
}
func TestLoginPassTopLevelWrapperExpandsAndNestedManualGiftStaysOwned(t *testing.T) {
raw := wire.AppendVarint(nil, 2, 1)
for _, f := range []struct {
n int
v uint64
}{{6, 3}, {6, 9}, {5, 0}, {5, 200}, {4, 125}, {4, 1}, {8, 1}, {8, 100}} {
raw = wire.AppendVarint(raw, f.n, f.v)
}
c := &CashRewardResolver{boxes: map[uint64]uint64{100: 1000, 200: 2000}, direct: map[uint64]bool{}, groups: map[uint64][]byte{1000: raw}}
got, err := c.ResolveGranted([]BattleReward{{Type: 9, ID: 100, Count: 1}})
if err != nil || len(got) != 2 || got[0].Type != 3 || got[0].Count != 125 || got[1].Type != 9 || got[1].ID != 200 {
t.Fatalf("login pass wrapper=%+v err=%v", got, err)
}
}
+20 -20
View File
@@ -4,8 +4,6 @@ import (
"database/sql"
"fmt"
"math"
"os"
"path/filepath"
)
// CharAwakeCost is one exact CharAwakeGrowthTable material row. Type 4 is
@@ -49,24 +47,11 @@ type CharImprintTarget struct {
}
func LoadCharAwakeDesign(root, version string) (*CharAwakeDesign, error) {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-char-awake-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
return loadCharAwakeDesign(db)
}
@@ -199,13 +184,28 @@ func loadCharAwakeDesign(db *sql.DB) (*CharAwakeDesign, error) {
if err != nil {
return nil, err
}
type stageRow struct {
id uint64
proto []byte
}
var sourceRows []stageRow
for rows.Next() {
var id uint64
var proto []byte
if err := rows.Scan(&id, &proto); err != nil {
var row stageRow
if err := rows.Scan(&row.id, &row.proto); err != nil {
rows.Close()
return nil, err
}
sourceRows = append(sourceRows, row)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
for _, row := range sourceRows {
id, proto := row.id, row.proto
growthID, _ := packedInts(proto, 1)
grade, _ := packedInts(proto, 9)
growthGrade, _ := packedInts(proto, 10)
@@ -0,0 +1,56 @@
package gamedata
import "fmt"
// ClearPackageRewardDesign matches ClearPackTable and ClearEvilCastleTable.
// Kind is the wire enum: zero story pack, one evil-castle tower.
type ClearPackageRewardDesign struct{ Kind, GroupID, TicketID, TargetID, Level, RandomBoxID, Type uint64 }
type ClearPackageCatalog struct{ Rewards []ClearPackageRewardDesign }
func LoadClearPackageCatalog(root, version string) (*ClearPackageCatalog, error) {
db, done, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer done()
c := &ClearPackageCatalog{}
for kind, table := range []string{"ClearPackTable", "ClearEvilCastleTable"} {
rows, err := db.Query("SELECT ProtoBuf FROM " + table)
if err != nil {
return nil, err
}
for rows.Next() {
var raw []byte
if err = rows.Scan(&raw); err != nil {
rows.Close()
return nil, err
}
v, err := cashScalars(raw, 1, 2, 3, 4, 5, 6)
if err != nil {
rows.Close()
return nil, err
}
r := ClearPackageRewardDesign{Kind: uint64(kind), TicketID: v[0], GroupID: v[1], Type: v[5]}
if kind == 0 {
r.TargetID = v[2]
r.Level = v[3]
r.RandomBoxID = v[4]
} else {
r.RandomBoxID = v[2]
r.Level = v[3]
r.TargetID = v[4]
}
if r.GroupID == 0 || r.TicketID == 0 || r.TargetID == 0 || r.RandomBoxID == 0 || r.Type > 1 {
rows.Close()
return nil, fmt.Errorf("gamedata: invalid clear-package reward")
}
c.Rewards = append(c.Rewards, r)
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
}
return c, nil
}
@@ -4,8 +4,6 @@ import (
"database/sql"
"fmt"
"math"
"os"
"path/filepath"
)
type CostumePotentialCost struct{ Type, ID, Count uint64 }
@@ -28,24 +26,11 @@ type CostumePotentialDesign struct {
}
func LoadCostumePotentialDesign(root, version string) (*CostumePotentialDesign, error) {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-costume-potential-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
return loadCostumePotentialDesign(db)
}
@@ -0,0 +1,310 @@
package gamedata
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
_ "modernc.org/sqlite"
)
const DefaultDatabaseCacheBytes int64 = 512 << 20
type databaseCacheKey struct {
Path, Version, Logical string
Size, Modified int64
}
type databaseCacheEntry struct {
db *sql.DB
size int64
refs int
used uint64
ready chan struct{}
err error
retired bool
}
// DatabaseCache owns read-only SQLite connections. A lease must be released
// after closing all rows; callers must never Close the returned shared DB.
// The budget bounds retained SQLite images, excluding transient decrypt buffers
// and SQLite page-cache overhead. Active leases may temporarily exceed it.
type DatabaseCache struct {
mu sync.Mutex
loadMu sync.Mutex
entries map[databaseCacheKey]*databaseCacheEntry
budget, bytes int64
clock uint64
closed bool
loader func(string, string, string) ([]byte, error)
}
func NewDatabaseCache(maxBytes int64) *DatabaseCache {
if maxBytes <= 0 {
maxBytes = DefaultDatabaseCacheBytes
}
return &DatabaseCache{budget: maxBytes, entries: map[databaseCacheKey]*databaseCacheEntry{}, loader: ReadDatabase}
}
var sharedDatabaseCache = NewDatabaseCache(DefaultDatabaseCacheBytes)
var sharedDatabaseCacheMu sync.RWMutex
var databaseMemoryID atomic.Uint64
func databaseKey(root, version, logical string) (databaseCacheKey, error) {
if _, err := DatabaseName(logical); err != nil {
return databaseCacheKey{}, err
}
if version == "" || version == "." || version == ".." || strings.ContainsAny(version, `/\`) {
return databaseCacheKey{}, fmt.Errorf("gamedata: invalid database version")
}
abs, err := filepath.Abs(filepath.Clean(root))
if err != nil {
return databaseCacheKey{}, err
}
abs, err = filepath.EvalSymlinks(abs)
if err != nil {
return databaseCacheKey{}, err
}
archive := filepath.Join(abs, version, "release", ArchiveName)
stat, err := os.Stat(archive)
if err != nil {
return databaseCacheKey{}, err
}
path := archive
if runtime.GOOS == "windows" {
path = strings.ToLower(path)
}
return databaseCacheKey{Path: path, Version: version, Logical: logical, Size: stat.Size(), Modified: stat.ModTime().UnixNano()}, nil
}
func memoryDatabase(plain []byte) (*sql.DB, error) {
db, err := sql.Open("sqlite", fmt.Sprintf("file:bd2-gamedata-%d?mode=memory&cache=private", databaseMemoryID.Add(1)))
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
conn, err := db.Conn(context.Background())
if err != nil {
db.Close()
return nil, err
}
err = conn.Raw(func(driver any) error {
d, ok := driver.(interface{ Deserialize([]byte) error })
if !ok {
return fmt.Errorf("gamedata: SQLite driver lacks Deserialize")
}
return d.Deserialize(plain)
})
if err == nil {
_, err = conn.ExecContext(context.Background(), "PRAGMA query_only=ON")
}
if err == nil {
_, err = conn.ExecContext(context.Background(), "PRAGMA cache_size=-4096")
}
if err == nil {
var n int
err = conn.QueryRowContext(context.Background(), "SELECT count(*) FROM sqlite_schema").Scan(&n)
}
closeErr := conn.Close()
if err == nil {
err = closeErr
}
if err != nil {
db.Close()
return nil, err
}
return db, nil
}
func (c *DatabaseCache) Open(root, version, logical string) (*sql.DB, func(), error) {
key, err := databaseKey(root, version, logical)
if err != nil {
return nil, nil, err
}
for {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil, nil, fmt.Errorf("gamedata: database cache closed")
}
if e, ok := c.entries[key]; ok {
if e.ready != nil {
ready := e.ready
c.mu.Unlock()
<-ready
if e.err != nil {
return nil, nil, e.err
}
continue
}
e.refs++
c.clock++
e.used = c.clock
c.mu.Unlock()
return e.db, c.release(e), nil
}
e := &databaseCacheEntry{ready: make(chan struct{})}
c.entries[key] = e
c.mu.Unlock()
// Serial loading limits peak encrypted + plaintext + SQLite image memory.
c.loadMu.Lock()
loadStarted := time.Now()
plain, loadErr := c.loader(root, version, logical)
var db *sql.DB
size := int64(len(plain))
if loadErr == nil {
current, e2 := databaseKey(root, version, logical)
if e2 != nil {
loadErr = e2
} else if current != key {
loadErr = fmt.Errorf("gamedata: archive changed during database load")
} else {
db, loadErr = memoryDatabase(plain)
}
}
plain = nil
c.loadMu.Unlock()
if loadErr == nil {
slog.Debug("GameData database cached", "logical", logical, "version", version, "image_bytes", size, "load_ms", float64(time.Since(loadStarted).Microseconds())/1000)
}
c.mu.Lock()
e.err = loadErr
ready := e.ready
e.ready = nil
if loadErr != nil || c.closed {
delete(c.entries, key)
if db != nil {
_ = db.Close()
}
if loadErr == nil {
e.err = fmt.Errorf("gamedata: database cache closed")
}
close(ready)
c.mu.Unlock()
return nil, nil, e.err
}
e.db = db
e.size = size
e.refs = 1
c.clock++
e.used = c.clock
c.bytes += size
// Retire earlier versions of a replaced archive. Existing leases remain
// valid until released, but cannot become a new request's database.
for oldKey, old := range c.entries {
if oldKey != key && oldKey.Path == key.Path && oldKey.Logical == key.Logical && old.ready == nil {
delete(c.entries, oldKey)
old.retired = true
if old.refs == 0 {
c.bytes -= old.size
_ = old.db.Close()
}
}
}
c.evictLocked()
close(ready)
c.mu.Unlock()
return db, c.release(e), nil
}
}
func (c *DatabaseCache) release(e *databaseCacheEntry) func() {
var once sync.Once
return func() {
once.Do(func() {
c.mu.Lock()
defer c.mu.Unlock()
e.refs--
if e.refs == 0 && e.retired {
c.bytes -= e.size
_ = e.db.Close()
}
c.evictLocked()
})
}
}
func (c *DatabaseCache) evictLocked() {
for c.bytes > c.budget {
var key databaseCacheKey
var oldest *databaseCacheEntry
for k, e := range c.entries {
if e.ready == nil && e.refs == 0 && (oldest == nil || e.used < oldest.used) {
key = k
oldest = e
}
}
if oldest == nil {
return
}
delete(c.entries, key)
c.bytes -= oldest.size
oldest.retired = true
_ = oldest.db.Close()
}
}
// Close retires all images; active queries finish before their final release.
func (c *DatabaseCache) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
c.closed = true
var first error
for key, e := range c.entries {
if e.ready != nil {
continue
}
delete(c.entries, key)
e.retired = true
if e.refs == 0 {
c.bytes -= e.size
if err := e.db.Close(); err != nil && first == nil {
first = err
}
}
}
return first
}
func (c *DatabaseCache) WithDatabase(root, version, logical string, query func(*sql.DB) error) error {
if query == nil {
return fmt.Errorf("gamedata: missing query callback")
}
db, release, err := c.Open(root, version, logical)
if err != nil {
return err
}
defer release()
return query(db)
}
func OpenDatabase(root, version, logical string) (*sql.DB, func(), error) {
sharedDatabaseCacheMu.RLock()
defer sharedDatabaseCacheMu.RUnlock()
return sharedDatabaseCache.Open(root, version, logical)
}
func WithDatabase(root, version, logical string, query func(*sql.DB) error) error {
if query == nil {
return fmt.Errorf("gamedata: missing query callback")
}
db, release, err := OpenDatabase(root, version, logical)
if err != nil {
return err
}
defer release()
return query(db)
}
// CloseDatabaseCache retires the current generation. Active leases finish
// normally; the next OpenDatabase starts with a fresh empty cache.
func CloseDatabaseCache() error {
sharedDatabaseCacheMu.Lock()
old := sharedDatabaseCache
sharedDatabaseCache = NewDatabaseCache(DefaultDatabaseCacheBytes)
sharedDatabaseCacheMu.Unlock()
return old.Close()
}
@@ -0,0 +1,322 @@
package gamedata
import (
"archive/zip"
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
)
func cachePlain(t *testing.T, value int) []byte {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err = db.Exec(fmt.Sprintf("CREATE TABLE test(value INTEGER);INSERT INTO test VALUES(%d)", value)); err != nil {
t.Fatal(err)
}
conn, err := db.Conn(context.Background())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
var raw []byte
err = conn.Raw(func(v any) error {
var e error
raw, e = v.(interface{ Serialize() ([]byte, error) }).Serialize()
return e
})
if err != nil {
t.Fatal(err)
}
return raw
}
func cacheArchive(t *testing.T, root string, marker byte) {
t.Helper()
dir := filepath.Join(root, "v1", "release")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, ArchiveName)
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
w := zip.NewWriter(f)
for _, logical := range []string{"common", "pack1", "pack2"} {
name, _ := DatabaseName(logical)
entry, err := w.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err = entry.Write([]byte{marker}); err != nil {
t.Fatal(err)
}
}
if err = w.Close(); err != nil {
t.Fatal(err)
}
if err = f.Close(); err != nil {
t.Fatal(err)
}
}
func TestDatabaseCacheSingleFlightReadOnlyAndLifetime(t *testing.T) {
root := t.TempDir()
cacheArchive(t, root, 1)
plain := cachePlain(t, 7)
c := NewDatabaseCache(1 << 20)
defer c.Close()
var loads atomic.Int32
c.loader = func(string, string, string) ([]byte, error) { loads.Add(1); return plain, nil }
var wg sync.WaitGroup
for i := 0; i < 12; i++ {
wg.Add(1)
go func() {
defer wg.Done()
err := c.WithDatabase(root, "v1", "common", func(db *sql.DB) error {
var n int
if err := db.QueryRow("SELECT value FROM test").Scan(&n); err != nil {
return err
}
if n != 7 {
return fmt.Errorf("wrong value")
}
if _, err := db.Exec("INSERT INTO test VALUES(8)"); err == nil {
return fmt.Errorf("write accepted")
}
return nil
})
if err != nil {
t.Error(err)
}
}()
}
wg.Wait()
if loads.Load() != 1 {
t.Fatal("duplicate decrypt", loads.Load())
}
db, release, err := c.Open(root, "v1", "common")
if err != nil {
t.Fatal(err)
}
if err = c.Close(); err != nil {
t.Fatal(err)
}
var n int
if err = db.QueryRow("SELECT value FROM test").Scan(&n); err != nil {
t.Fatal("active lease closed", err)
}
release()
release()
if err = db.Ping(); err == nil {
t.Fatal("released retired database alive")
}
}
func TestDatabaseCacheFailureRetryReplacementAndEviction(t *testing.T) {
root := t.TempDir()
cacheArchive(t, root, 1)
plain := cachePlain(t, 1)
c := NewDatabaseCache(int64(len(plain)))
defer c.Close()
var loads int
c.loader = func(string, string, string) ([]byte, error) {
loads++
if loads == 1 {
return nil, fmt.Errorf("failure")
}
return plain, nil
}
if _, _, err := c.Open(root, "v1", "common"); err == nil {
t.Fatal("failure missing")
}
first, release, err := c.Open(root, "v1", "common")
if err != nil {
t.Fatal(err)
}
release()
second, done, err := c.Open(filepath.Join(root, "."), "v1", "common")
if err != nil || first != second || loads != 2 {
t.Fatal("canonical hit failed", err, loads)
}
done()
_, done, err = c.Open(root, "v1", "pack1")
if err != nil {
t.Fatal(err)
}
done()
if err = first.Ping(); err == nil {
t.Fatal("LRU not evicted")
}
old, oldRelease, err := c.Open(root, "v1", "pack1")
if err != nil {
t.Fatal(err)
}
cacheArchive(t, root, 2)
plain = cachePlain(t, 9)
current, currentRelease, err := c.Open(root, "v1", "pack1")
if err != nil {
t.Fatal(err)
}
if old == current {
t.Fatal("replacement reused stale image")
}
var n int
if err = current.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 9 {
t.Fatal("replacement wrong", n, err)
}
oldRelease()
currentRelease()
}
func TestDatabaseCacheMutationDuringLoadFailsAndDoesNotPoison(t *testing.T) {
root := t.TempDir()
cacheArchive(t, root, 1)
plain := cachePlain(t, 3)
c := NewDatabaseCache(1 << 20)
defer c.Close()
calls := 0
c.loader = func(string, string, string) ([]byte, error) {
calls++
if calls == 1 {
cacheArchive(t, root, 2)
}
return plain, nil
}
if _, _, err := c.Open(root, "v1", "common"); err == nil {
t.Fatal("replaced archive was cached")
}
db, release, err := c.Open(root, "v1", "common")
if err != nil {
t.Fatal(err)
}
defer release()
var n int
if err = db.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 3 || calls != 2 {
t.Fatal(n, calls, err)
}
}
func TestSharedDatabaseCacheCloseStartsFreshGeneration(t *testing.T) {
root := t.TempDir()
cacheArchive(t, root, 1)
plain := cachePlain(t, 11)
sharedDatabaseCacheMu.Lock()
previous := sharedDatabaseCache
current := NewDatabaseCache(1 << 20)
current.loader = func(string, string, string) ([]byte, error) { return plain, nil }
sharedDatabaseCache = current
sharedDatabaseCacheMu.Unlock()
t.Cleanup(func() {
_ = CloseDatabaseCache()
sharedDatabaseCacheMu.Lock()
empty := sharedDatabaseCache
sharedDatabaseCache = previous
sharedDatabaseCacheMu.Unlock()
_ = empty.Close()
})
old, releaseOld, err := OpenDatabase(root, "v1", "common")
if err != nil {
t.Fatal(err)
}
defer releaseOld()
if err = CloseDatabaseCache(); err != nil {
t.Fatal(err)
}
sharedDatabaseCacheMu.Lock()
fresh := sharedDatabaseCache
fresh.loader = func(string, string, string) ([]byte, error) { return plain, nil }
sharedDatabaseCacheMu.Unlock()
if fresh == current {
t.Fatal("global generation not replaced")
}
var n int
if err = old.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 11 {
t.Fatal("retired active lease unavailable", n, err)
}
newDB, releaseNew, err := OpenDatabase(root, "v1", "common")
if err != nil {
t.Fatal(err)
}
defer releaseNew()
if newDB == old {
t.Fatal("new generation reused old handle")
}
if err = newDB.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 11 {
t.Fatal(n, err)
}
releaseOld()
if err = old.Ping(); err == nil {
t.Fatal("retired released generation retained database")
}
}
func TestDatabaseCacheRootAndVersionIsolation(t *testing.T) {
rootA, rootB := t.TempDir(), t.TempDir()
cacheArchive(t, rootA, 1)
cacheArchive(t, rootB, 1)
archive, err := os.ReadFile(filepath.Join(rootA, "v1", "release", ArchiveName))
if err != nil {
t.Fatal(err)
}
v2dir := filepath.Join(rootA, "v2", "release")
if err = os.MkdirAll(v2dir, 0700); err != nil {
t.Fatal(err)
}
if err = os.WriteFile(filepath.Join(v2dir, ArchiveName), archive, 0600); err != nil {
t.Fatal(err)
}
plainA, plainB, plainV2 := cachePlain(t, 1), cachePlain(t, 2), cachePlain(t, 3)
c := NewDatabaseCache(1 << 20)
defer c.Close()
loads := 0
c.loader = func(root, version, _ string) ([]byte, error) {
loads++
if version == "v2" {
return plainV2, nil
}
if root == rootB {
return plainB, nil
}
return plainA, nil
}
type item struct {
root, version string
want int
}
handles := map[*sql.DB]bool{}
for _, input := range []item{{rootA, "v1", 1}, {rootB, "v1", 2}, {rootA, "v2", 3}} {
db, release, err := c.Open(input.root, input.version, "common")
if err != nil {
t.Fatal(err)
}
var value int
if err = db.QueryRow("SELECT value FROM test").Scan(&value); err != nil || value != input.want {
release()
t.Fatal("root/version crossed", value, input.want, err)
}
if handles[db] {
release()
t.Fatal("isolated inputs shared handle")
}
handles[db] = true
release()
}
if loads != 3 {
t.Fatal("isolated inputs did not load separately", loads)
}
for _, input := range []item{{rootA, "v1", 1}, {rootB, "v1", 2}, {rootA, "v2", 3}} {
if err = c.WithDatabase(input.root, input.version, "common", func(db *sql.DB) error { return db.Ping() }); err != nil {
t.Fatal(err)
}
}
if loads != 3 {
t.Fatal("isolated caches did not hit", loads)
}
}
+13
View File
@@ -187,6 +187,19 @@ func (d *DispatchDesign) Roll(count uint64, draw func(uint64) (uint64, error)) (
budget--
if g.Drop == 1 {
for _, e := range g.Entries {
if e.Weight > 100 {
return fmt.Errorf("gamedata: direct dispatch percent exceeds 100")
}
v, err := draw(100)
if err != nil {
return err
}
if v >= 100 {
return fmt.Errorf("gamedata: sampler out of range")
}
if v >= e.Weight {
continue
}
if err := emit(e); err != nil {
return err
}
+3 -3
View File
@@ -6,7 +6,7 @@ import (
)
func TestDispatchWeightedNestedRewards(t *testing.T) {
leaf := &dispatchGroup{Drop: 1, Entries: []dispatchEntry{{Reward: BattleReward{Type: 8, ID: 44, Count: 3}}}}
leaf := &dispatchGroup{Drop: 1, Entries: []dispatchEntry{{Weight: 100, Reward: BattleReward{Type: 8, ID: 44, Count: 3}}}}
weighted := &dispatchGroup{Drop: 0, Count: 2, Entries: []dispatchEntry{{Reward: BattleReward{Type: 9, Count: 1}, Weight: 90, Child: leaf}, {Reward: BattleReward{Type: 4, Count: 5}, Weight: 10}}}
d := &DispatchDesign{Rewards: []BattleReward{{Type: 9, ID: 7, Count: 1}}, boxes: map[uint64]*dispatchGroup{7: weighted}}
draws := 0
@@ -15,7 +15,7 @@ func TestDispatchWeightedNestedRewards(t *testing.T) {
return 0, fmt.Errorf("limit %d", n)
}
draws++
if draws%2 == 0 {
if draws%3 == 0 {
return 95, nil
}
return 0, nil
@@ -27,7 +27,7 @@ func TestDispatchWeightedNestedRewards(t *testing.T) {
for _, v := range r {
totals[v.Type] += v.Count
}
if totals[8] != 6 || totals[4] != 10 || draws != 4 {
if totals[8] != 6 || totals[4] != 10 || draws != 6 {
t.Fatalf("rewards %+v draws %d", r, draws)
}
if _, err = d.Roll(1, func(n uint64) (uint64, error) { return n, nil }); err == nil {
@@ -3,32 +3,17 @@ package gamedata
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sort"
)
// IncludeCollaborationURWeapons extends only the UR equipment ticket pool.
// Limited costumes identify eligible owners; equipment IDs remain version data.
func (c *EquipmentGachaCatalog) IncludeCollaborationURWeapons(root, version string) error {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return err
}
dir, err := os.MkdirTemp("", "bd2-collaboration-equipment-")
if err != nil {
return err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return err
}
defer db.Close()
defer release()
return c.includeCollaborationURWeapons(db)
}
+20 -16
View File
@@ -61,13 +61,30 @@ func loadEquipmentCraftDesign(db *sql.DB) (*EquipmentCraftDesign, error) {
if err != nil {
return nil, err
}
type makingRow struct {
id uint64
raw []byte
}
var makingRows []makingRow
for rows.Next() {
var id uint64
var raw []byte
if err := rows.Scan(&id, &raw); err != nil {
var row makingRow
if err := rows.Scan(&row.id, &row.raw); err != nil {
rows.Close()
return nil, err
}
makingRows = append(makingRows, row)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
// Recipes perform nested random-box, reward and equipment queries only
// after the source result set releases the single database connection.
for _, row := range makingRows {
id, raw := row.id, row.raw
counts, _ := packedInts(raw, 4)
materialIDs, _ := packedInts(raw, 5)
materialTypes, _ := packedInts(raw, 6)
@@ -78,45 +95,32 @@ func loadEquipmentCraftDesign(db *sql.DB) (*EquipmentCraftDesign, error) {
if len(counts) == 0 || len(counts) != len(materialIDs) || len(counts) != len(materialTypes) ||
len(resultCounts) != 1 || resultCounts[0] != 1 || len(boxIDs) != 1 ||
len(resultTypes) != 1 || resultTypes[0] != 9 || len(talentLevels) != 1 || talentLevels[0] == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment making recipe %d malformed", id)
}
var box []byte
if err := db.QueryRow("SELECT ProtoBuf FROM RandomBoxTable WHERE id=?", boxIDs[0]).Scan(&box); err != nil {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment making recipe %d random box: %w", id, err)
}
groups, _ := packedInts(box, 9)
if len(groups) != 1 || groups[0] == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment making random box %d malformed", boxIDs[0])
}
results, equipmentOnly, err := classifyEquipmentRewardPool(db, groups[0])
if err != nil || !equipmentOnly || len(results) == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment making recipe %d reward group: %w", id, err)
}
recipe := EquipmentCraftRecipe{ID: id, ResultCount: resultCounts[0], TalentLevel: talentLevels[0], Results: results}
for i := range counts {
if counts[i] == 0 || materialIDs[i] == 0 || materialTypes[i] == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment making recipe %d invalid material", id)
}
recipe.Costs = append(recipe.Costs, PromotionCost{Type: materialTypes[i], ID: materialIDs[i], Count: counts[i]})
}
if err := design.loadEquipmentPool(db, results); err != nil {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment making recipe %d: %w", id, err)
}
design.Recipes[id] = recipe
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
talents := make(map[uint64]equipmentMakingTalent)
rows, err = db.Query("SELECT id,ProtoBuf FROM TalentTable")
+20 -19
View File
@@ -7,8 +7,6 @@ import (
"fmt"
"math"
"math/big"
"os"
"path/filepath"
"sort"
)
@@ -62,24 +60,11 @@ type EquipmentOptionChoice struct{ GroupID, ID uint64 }
// LoadEquipmentGachaGroups loads only groups selected by a captured dynamic
// schedule. Presence in GameData alone does not mean a banner is open.
func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*EquipmentGachaCatalog, error) {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-equipment-gacha-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
c := &EquipmentGachaCatalog{Gachas: map[uint64]EquipmentGacha{}, groups: map[uint64]EquipmentGachaGroup{}, byGacha: map[uint64]uint64{}, equipment: map[uint64]EquipmentDesign{}}
for _, groupID := range groupIDs {
var raw []byte
@@ -146,6 +131,11 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme
if err != nil {
return nil, err
}
type ticketCandidate struct {
id, count, reward uint64
tickets []uint64
}
var candidates []ticketCandidate
for rows.Next() {
var id uint64
var raw []byte
@@ -164,7 +154,18 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme
if len(count) != 1 || count[0] == 0 || len(reward) != 1 || len(tickets) == 0 || len(price) != 0 || len(kind) != 0 {
continue
}
pool, equipmentOnly, err := classifyEquipmentRewardPool(db, reward[0])
candidates = append(candidates, ticketCandidate{id, count[0], reward[0], append([]uint64(nil), tickets...)})
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
for _, candidate := range candidates {
id := candidate.id
pool, equipmentOnly, err := classifyEquipmentRewardPool(db, candidate.reward)
if err != nil {
rows.Close()
return nil, fmt.Errorf("gamedata: ticket gacha %d: %w", id, err)
@@ -172,7 +173,7 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme
if !equipmentOnly {
continue
}
g := EquipmentGacha{ID: id, Count: int(count[0]), TicketIDs: append([]uint64(nil), tickets...), Pool: pool, TicketOnly: true}
g := EquipmentGacha{ID: id, Count: int(candidate.count), TicketIDs: candidate.tickets, Pool: pool, TicketOnly: true}
c.Gachas[id] = g
for _, item := range pool {
if err := c.loadEquipmentTree(db, item); err != nil {
@@ -4,8 +4,6 @@ import (
"database/sql"
"fmt"
"math"
"os"
"path/filepath"
)
// EquipmentOptionRerollDesign is the static equipment-option refinement
@@ -57,24 +55,11 @@ type EquipmentOptionRerollRoll struct {
}
func LoadEquipmentOptionRerollDesign(root, version string) (*EquipmentOptionRerollDesign, error) {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-equipment-option-reroll-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
return loadEquipmentOptionRerollDesign(db)
}
+2 -17
View File
@@ -3,31 +3,16 @@ package gamedata
import (
"database/sql"
"fmt"
"os"
"path/filepath"
)
// LoadEquipmentSlots reads EquipmentTable.SlotType for every installed item.
// SlotType is a proto3 enum, so an absent field is the valid first slot (0).
func LoadEquipmentSlots(root, version string) (map[uint64]uint64, error) {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-equipment-slots-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
return loadEquipmentSlots(db)
}
@@ -4,8 +4,6 @@ import (
"database/sql"
"fmt"
"math"
"os"
"path/filepath"
)
// EquipmentSmeltingDesign contains only static 2.35.10 GameData facts. It does
@@ -37,24 +35,11 @@ type EquipmentSmeltingMileage struct {
}
func LoadEquipmentSmeltingDesign(root, version string) (*EquipmentSmeltingDesign, error) {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-equipment-smelting-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
return loadEquipmentSmeltingDesign(db)
}
@@ -6,8 +6,6 @@ import (
"encoding/binary"
"fmt"
"math"
"os"
"path/filepath"
)
type EquipmentUpgradeLevel struct {
@@ -29,24 +27,11 @@ type EquipmentUpgradeDesign struct {
}
func LoadEquipmentUpgradeDesign(root, version string) (*EquipmentUpgradeDesign, error) {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-equipment-upgrade-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
return loadEquipmentUpgradeDesign(db)
}
@@ -0,0 +1,150 @@
package gamedata
import (
"bd2server/internal/server/wire"
"fmt"
"sort"
)
// EventActionRow preserves current-version design fields without introducing
// hard-coded event IDs. Runtime modules consume the named table and fields.
type EventActionRow struct {
Values map[int]uint64
Rewards []Reward
EventRewards []Reward
Text map[int]string
}
func (r EventActionRow) V(field int) uint64 { return r.Values[field] }
type EventActionsDesign struct {
Tables map[string][]EventActionRow
SpawnRewards map[[2]uint64]Reward
}
func (d *EventActionsDesign) Row(table string, idField int, id uint64) (EventActionRow, bool) {
for _, r := range d.Tables[table] {
if r.V(idField) == id {
return r, true
}
}
return EventActionRow{}, false
}
func LoadEventActionsDesign(root, version string) (*EventActionsDesign, error) {
db, done, e := openStatDatabase(root, version)
if e != nil {
return nil, e
}
defer done()
d := &EventActionsDesign{Tables: map[string][]EventActionRow{}, SpawnRewards: map[[2]uint64]Reward{}}
for _, table := range []string{"FieldEventDefaultTable", "FieldSpawnEventTable", "FieldEventMonsterTable", "FireworksTable", "VotingEventTable", "VotingCandidateTable", "VotingRoundTable", "VotingCountRewardTable", "VotingSeasonTable", "FriendshipSpecialEpisodeTable", "NpcQuizTable", "TacticsBingoGroupTable", "TacticsBingoTable", "CafeteriaDefaultTable", "CafeteriaEventTable"} {
rows, e := db.Query("SELECT ProtoBuf FROM " + table)
if e != nil {
return nil, e
}
for rows.Next() {
var p []byte
if e = rows.Scan(&p); e != nil {
rows.Close()
return nil, e
}
r := EventActionRow{Values: map[int]uint64{}, Text: map[int]string{}}
if e = wire.Walk(p, func(f wire.Field) error {
if f.Type == 0 {
v, _, er := wire.Varint(p, f.Number)
if er != nil {
return er
}
r.Values[f.Number] = v
} else if f.Type == 2 {
r.Text[f.Number] = string(f.Value)
}
return nil
}); e != nil {
rows.Close()
return nil, e
}
var array [3]int
switch table {
case "FriendshipSpecialEpisodeTable":
array = [3]int{9, 8, 7}
case "NpcQuizTable":
array = [3]int{12, 11, 10}
}
if array[0] > 0 {
rs, e := monsterHuntRewardArrays(p, array[0], array[1], array[2])
if e != nil {
rows.Close()
return nil, e
}
for _, x := range rs {
r.Rewards = append(r.Rewards, Reward{x.Type, x.ID, x.Count})
}
}
if table == "FriendshipSpecialEpisodeTable" {
rs, e := monsterHuntRewardArrays(p, 4, 3, 2)
if e != nil {
rows.Close()
return nil, e
}
for _, x := range rs {
r.EventRewards = append(r.EventRewards, Reward{x.Type, x.ID, x.Count})
}
}
if table == "FireworksTable" {
r.Rewards = []Reward{{r.V(9), r.V(8), r.V(7)}}
}
if table == "VotingCountRewardTable" {
r.Rewards = []Reward{{r.V(5), r.V(4), r.V(3)}}
}
d.Tables[table] = append(d.Tables[table], r)
}
e = rows.Err()
rows.Close()
if e != nil {
return nil, e
}
}
packs := map[uint64]bool{}
for _, r := range d.Tables["FieldEventMonsterTable"] {
packs[r.V(5)] = true
}
for pack := range packs {
pdb, closePack, e := openPackDatabase(root, version, int(pack))
if e != nil {
return nil, e
}
for _, r := range d.Tables["FieldEventMonsterTable"] {
if r.V(5) != pack {
continue
}
var p []byte
if e = pdb.QueryRow("SELECT ProtoBuf FROM FieldMonsterTable WHERE id=?", r.V(1)).Scan(&p); e != nil {
closePack()
return nil, fmt.Errorf("event spawn monster %d: %w", r.V(1), e)
}
t, e := optionalScalar(p, 29)
if e != nil {
closePack()
return nil, e
}
id, e := optionalScalar(p, 28)
if e != nil {
closePack()
return nil, e
}
count, e := optionalScalar(p, 27)
if e != nil {
closePack()
return nil, e
}
d.SpawnRewards[[2]uint64{pack, r.V(1)}] = Reward{t, id, count}
}
closePack()
}
for table, rows := range d.Tables {
sort.Slice(rows, func(i, j int) bool { return rows[i].V(2) < rows[j].V(2) })
d.Tables[table] = rows
}
return d, nil
}
@@ -0,0 +1,24 @@
package gamedata
import (
"os"
"testing"
)
func TestInstalledEventActions23510(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
d, e := LoadEventActionsDesign(root, "20260923193640")
if e != nil {
t.Fatal(e)
}
if len(d.Tables["VotingEventTable"]) != 6 || len(d.Tables["FriendshipSpecialEpisodeTable"]) != 120 || len(d.Tables["NpcQuizTable"]) != 43 {
t.Fatal("event actions incomplete")
}
r := d.SpawnRewards[[2]uint64{3009, 3001}]
if r.Type != 4 || r.Count != 10000 {
t.Fatalf("spawn reward mismatch %+v", r)
}
}
+26
View File
@@ -0,0 +1,26 @@
package gamedata
func LoadEventAPDesign(root, version string) (map[uint64]uint64, HuntingAPDesign, error) {
d, e := LoadHuntingAPDesign(root, version)
if e != nil {
return nil, d, e
}
db, done, e := openStatDatabase(root, version)
if e != nil {
return nil, d, e
}
defer done()
var raw []byte
if e = db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&raw); e != nil {
return nil, d, e
}
caps := map[uint64]uint64{}
for typ, field := range map[uint64]int{30: 43, 32: 113} {
n, e := optionalScalar(raw, field)
if e != nil {
return nil, d, e
}
caps[typ] = n
}
return caps, d, nil
}
@@ -0,0 +1,96 @@
package gamedata
import (
"bd2server/internal/server/wire"
"encoding/binary"
"fmt"
)
type EventBattleChallenge struct {
Type, Value1, Value2 uint64
Reward BattleReward
}
type EventBattleChallenges map[uint64][]EventBattleChallenge
func LoadEventBattleChallenges(root, version string) (EventBattleChallenges, error) {
db, done, e := openStatDatabase(root, version)
if e != nil {
return nil, e
}
defer done()
rows, e := db.Query("SELECT id,ProtoBuf FROM BattleDeckTable")
if e != nil {
return nil, e
}
defer rows.Close()
out := EventBattleChallenges{}
for rows.Next() {
var id uint64
var raw []byte
if e = rows.Scan(&id, &raw); e != nil {
return nil, e
}
fields := map[int][]uint64{}
for _, n := range []int{6, 7, 8, 9, 10, 11} {
v, e := packedInts(raw, n)
if e != nil {
return nil, e
}
fields[n] = v
}
n := len(fields[6])
if n == 0 {
continue
}
for _, f := range []int{7, 8, 9, 10, 11} {
if len(fields[f]) != n {
return nil, fmt.Errorf("gamedata: event challenge arrays mismatch deck%d", id)
}
}
for i, t := range fields[6] {
if t < 1 || t > 8 {
return nil, fmt.Errorf("gamedata: unsupported challenge type%d", t)
}
out[id] = append(out[id], EventBattleChallenge{Type: t, Value1: fields[7][i], Value2: fields[8][i], Reward: BattleReward{Type: fields[11][i], ID: fields[10][i], Count: fields[9][i]}})
}
}
return out, rows.Err()
}
// VerifySubmittedChallenges uses the client's simulation reports. Conditions
// absent from BattleEnd (turn count/used skill/team elements) are accepted as
// explicit local simulation attestations after static index/type validation.
func VerifySubmittedChallenges(req []byte, definitions []EventBattleChallenge) ([]uint64, error) {
var indexes []uint64
seen := map[uint64]bool{}
e := wire.Walk(req, func(f wire.Field) error {
if f.Number != 5 {
return nil
}
var v []uint64
var e error
if f.Type == 0 {
n, k := binary.Uvarint(f.Value)
if k <= 0 {
return wire.ErrMalformed
}
v = []uint64{n}
} else if f.Type == 2 {
v, e = packedInts(wire.AppendBytes(nil, 5, f.Value), 5)
} else {
return wire.ErrMalformed
}
if e != nil {
return e
}
for _, i := range v {
if i >= uint64(len(definitions)) || seen[i] {
return fmt.Errorf("gamedata: invalid challenge index")
}
seen[i] = true
indexes = append(indexes, i)
}
return nil
})
return indexes, e
}
@@ -0,0 +1,32 @@
package gamedata
import (
"bd2server/internal/server/wire"
"os"
"testing"
)
func TestEventBattleChallengeInstalledSmoke(t *testing.T) {
root, version := os.Getenv("BD2_GAMEDATA_ROOT"), os.Getenv("BD2_GAMEDATA_VERSION")
if root == "" || version == "" {
t.Skip("installed design env required")
}
d, e := LoadEventBattleChallenges(root, version)
if e != nil {
t.Fatal(e)
}
if len(d) == 0 {
t.Fatal("empty challenges")
}
}
func TestChallengeIndexesPackedAndDuplicate(t *testing.T) {
definitions := []EventBattleChallenge{{Type: 8}, {Type: 2, Value1: 8}}
req := wire.AppendBytes(nil, 5, []byte{0, 1})
out, e := VerifySubmittedChallenges(req, definitions)
if e != nil || len(out) != 2 {
t.Fatal(out, e)
}
if _, e = VerifySubmittedChallenges(wire.AppendBytes(nil, 5, []byte{0, 0}), definitions); e == nil {
t.Fatal("duplicate index")
}
}
@@ -0,0 +1,101 @@
package gamedata
import "fmt"
type EventExchangeEntry struct {
ID, Page, KeyType, Ratio, LimitedRatio, SetCount uint64
Reward Reward
}
type EventExchangeGroup struct {
ID, StartPage, EndPage, FreeCount, FreeType, UnlockRatio uint64
Repeat bool
Cost Reward
Entries []EventExchangeEntry
}
type EventExchangeCatalog struct{ Groups map[uint64]EventExchangeGroup }
func LoadEventExchangeCatalog(root, version string) (*EventExchangeCatalog, error) {
db, done, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer done()
d := &EventExchangeCatalog{Groups: map[uint64]EventExchangeGroup{}}
rows, err := db.Query("SELECT id,ProtoBuf FROM EventCoinExchangeGroupTable ORDER BY id")
if err != nil {
return nil, err
}
for rows.Next() {
var id uint64
var raw []byte
if err = rows.Scan(&id, &raw); err != nil {
rows.Close()
return nil, err
}
g := EventExchangeGroup{ID: id}
for n, p := range map[int]*uint64{22: &g.StartPage, 4: &g.EndPage, 7: &g.FreeCount, 8: &g.FreeType, 25: &g.UnlockRatio, 14: &g.Cost.Count, 15: &g.Cost.ID, 16: &g.Cost.Type} {
*p, err = optionalScalar(raw, n)
if err != nil {
rows.Close()
return nil, err
}
}
repeat, e := optionalScalar(raw, 12)
if e != nil {
rows.Close()
return nil, e
}
g.Repeat = repeat != 0
if g.StartPage == 0 || g.EndPage < g.StartPage || g.Cost.Count == 0 || g.Cost.Type == 0 || g.FreeType > 2 || g.ID > 2147483647 || g.EndPage > 2147483647 || g.Cost.Count > 2147483647 || g.FreeCount > 2147483647 {
rows.Close()
return nil, fmt.Errorf("gamedata: invalid exchange group %d", id)
}
d.Groups[id] = g
}
if err = rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
rows, err = db.Query("SELECT groupId,id,pageId,ProtoBuf FROM EventCoinExchangeTable ORDER BY groupId,id")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var group, id, page uint64
var raw []byte
if err = rows.Scan(&group, &id, &page, &raw); err != nil {
return nil, err
}
g, ok := d.Groups[group]
if !ok {
return nil, fmt.Errorf("gamedata: exchange group missing")
}
e := EventExchangeEntry{ID: id, Page: page}
for n, p := range map[int]*uint64{3: &e.KeyType, 4: &e.LimitedRatio, 6: &e.Ratio, 7: &e.Reward.Count, 8: &e.Reward.ID, 9: &e.Reward.Type, 10: &e.SetCount} {
*p, err = optionalScalar(raw, n)
if err != nil {
return nil, err
}
}
if e.ID == 0 || e.Page < g.StartPage || e.Page > g.EndPage || e.SetCount == 0 || e.Ratio == 0 || e.Reward.Type == 0 || e.Reward.Count == 0 || e.SetCount > 2147483647 || e.Reward.Count > 2147483647 || e.ID > 2147483647 {
return nil, fmt.Errorf("gamedata: invalid exchange entry %d:%d", group, id)
}
g.Entries = append(g.Entries, e)
d.Groups[group] = g
}
return d, rows.Err()
}
func (g EventExchangeGroup) Page(page uint64) []EventExchangeEntry {
if page > g.EndPage {
page = g.EndPage
}
var out []EventExchangeEntry
for _, e := range g.Entries {
if e.Page == page {
out = append(out, e)
}
}
return out
}
+213
View File
@@ -0,0 +1,213 @@
package gamedata
import "fmt"
type EventGameReward struct {
ID, Count, LineType, LineIndex, Slot, Weight, Weight2 uint64
Members []uint64
Rewards []BattleReward
}
type EventGame struct {
ID, Type, Columns, CostType, CostID, Cost, ScaffoldGroup, Controller, Free, Pity, MaxConsume uint64
Cells, Lines, Complete []EventGameReward
Moves []struct{ ID, Min, Max uint64 }
RewardGroup uint64
}
func LoadEventGame(root, version string, kind, id uint64) (*EventGame, error) {
db, done, e := openStatDatabase(root, version)
if e != nil {
return nil, e
}
defer done()
tables := map[uint64]string{12: "MiniGameBoardTable", 13: "MiniGameBingoTable", 17: "MiniGamePuzzleEventTable", 19: "MiniGameRouletteTable"}
table := tables[kind]
if table == "" {
return nil, fmt.Errorf("gamedata: unsupported event game")
}
var raw []byte
if e = db.QueryRow("SELECT ProtoBuf FROM "+table+" WHERE id=?", id).Scan(&raw); e != nil {
return nil, e
}
d := &EventGame{ID: id, Type: kind}
var cellGroup, lineGroup, completeGroup uint64
fields := map[int]*uint64{}
switch kind {
case 12:
fields = map[int]*uint64{4: &d.Cost, 5: &d.CostID, 6: &d.CostType, 7: &completeGroup, 9: &d.Controller, 11: &d.ScaffoldGroup}
case 13:
fields = map[int]*uint64{1: &completeGroup, 2: &lineGroup, 3: &cellGroup, 5: &d.Columns, 7: &d.Cost, 8: &d.CostID, 9: &d.CostType}
case 17:
fields = map[int]*uint64{1: &d.Columns, 3: &d.Cost, 4: &d.CostID, 5: &d.CostType, 6: &completeGroup, 7: &cellGroup}
case 19:
fields = map[int]*uint64{1: &d.Free, 3: &d.Cost, 4: &d.CostID, 5: &d.MaxConsume, 6: &d.CostType, 7: &d.Pity, 8: &completeGroup, 9: &cellGroup}
}
for n, p := range fields {
*p, e = optionalScalar(raw, n)
if e != nil {
return nil, e
}
}
d.RewardGroup = cellGroup
load := func(table string, group uint64, mode string) ([]EventGameReward, error) {
rows, e := db.Query("SELECT ProtoBuf FROM "+table+" WHERE groupId=? ORDER BY id", group)
if e != nil {
return nil, e
}
defer rows.Close()
var out []EventGameReward
for rows.Next() {
var b []byte
if e = rows.Scan(&b); e != nil {
return nil, e
}
r := EventGameReward{}
idField, countField, typ, ids, counts := 3, 1, 6, 5, 4
switch mode {
case "scaffold":
idField = 2
countField = 0
typ = 5
ids = 4
counts = 3
case "line":
typ = 8
ids = 7
counts = 6
r.LineType, e = optionalScalar(b, 5)
if e != nil {
return nil, e
}
r.LineIndex, e = optionalScalar(b, 4)
case "roulette":
idField = 2
countField = 0
typ = 7
ids = 6
counts = 5
r.Slot, e = optionalScalar(b, 8)
if e != nil {
return nil, e
}
r.Weight, e = optionalScalar(b, 3)
if e != nil {
return nil, e
}
r.Weight2, e = optionalScalar(b, 4)
case "puzzlecomplete":
typ = 7
ids = 6
counts = 5
r.Members, e = packedInts(b, 4)
case "puzzle":
r.Slot, e = optionalScalar(b, 7)
}
if e != nil {
return nil, e
}
r.ID, e = optionalScalar(b, idField)
if e != nil {
return nil, e
}
if countField > 0 {
r.Count, e = optionalScalar(b, countField)
if e != nil {
return nil, e
}
}
r.Rewards, e = eventGameRewards(b, typ, ids, counts)
if e != nil {
return nil, e
}
out = append(out, r)
}
return out, rows.Err()
}
switch kind {
case 12:
d.Cells, e = load("MiniGameScaffoldTable", d.ScaffoldGroup, "scaffold")
if e == nil {
d.Complete, e = load("MiniGameCompleteRewardTable", completeGroup, "default")
}
if e != nil {
return nil, e
}
rows, e := db.Query("SELECT ProtoBuf FROM MiniGameMoveControllerTable WHERE groupId=? ORDER BY id", d.Controller)
if e != nil {
return nil, e
}
for rows.Next() {
var b []byte
if e = rows.Scan(&b); e != nil {
rows.Close()
return nil, e
}
i, e := optionalScalar(b, 3)
if e != nil {
rows.Close()
return nil, e
}
min, _ := optionalScalar(b, 5)
max, _ := optionalScalar(b, 4)
d.Moves = append(d.Moves, struct{ ID, Min, Max uint64 }{i, min, max})
}
e = rows.Err()
rows.Close()
case 13:
d.Cells, e = load("BingoRewardGroupTable", cellGroup, "default")
if e == nil {
d.Lines, e = load("BingoLineRewardGroupTable", lineGroup, "line")
}
if e == nil {
d.Complete, e = load("BingoCompleteRewardGroupTable", completeGroup, "default")
}
case 17:
d.Cells, e = load("PuzzleRewardGroupTable", cellGroup, "puzzle")
if e == nil {
d.Complete, e = load("PuzzleCompleteRewardGroupTable", completeGroup, "puzzlecomplete")
}
case 19:
d.Cells, e = load("RouletteRewardGroupTable", cellGroup, "roulette")
if e == nil {
d.Complete, e = load("RouletteAccumulatedRewardTable", completeGroup, "default")
}
}
if e != nil {
return nil, e
}
if d.Cost == 0 || len(d.Cells) == 0 {
return nil, fmt.Errorf("gamedata: empty event game rules")
}
return d, nil
}
func eventGameRewards(raw []byte, typ, ids, counts int) ([]BattleReward, error) {
ts, e := packedInts(raw, typ)
if e != nil {
return nil, e
}
is, e := packedInts(raw, ids)
if e != nil {
return nil, e
}
cs, e := packedInts(raw, counts)
if e != nil {
return nil, e
}
if len(ts) == 0 && len(cs) == 0 {
return nil, nil
}
if len(is) == 0 {
is = make([]uint64, len(ts))
}
if len(ts) != len(is) || len(ts) != len(cs) {
return nil, fmt.Errorf("gamedata: event game reward arrays differ")
}
var out []BattleReward
for i, t := range ts {
if t != 0 && cs[i] != 0 {
out = append(out, BattleReward{Type: t, ID: is[i], Count: cs[i]})
}
}
return out, nil
}
@@ -0,0 +1,31 @@
package gamedata
import (
"os"
"testing"
)
func TestEventGameInstalledRules(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
for _, kind := range []uint64{12, 13, 17, 19} {
d, e := LoadEventGame(root, "20260923193640", kind, 1)
if e != nil {
t.Fatalf("kind=%d %v", kind, e)
}
if d.Type != kind || d.Cost == 0 || len(d.Cells) == 0 {
t.Fatalf("kind=%d invalid %+v", kind, d)
}
if kind == 12 && len(d.Moves) == 0 {
t.Fatal("no moves")
}
if kind == 13 && len(d.Lines) == 0 {
t.Fatal("no lines")
}
if kind == 17 && len(d.Complete) == 0 {
t.Fatal("no words")
}
}
}
@@ -0,0 +1,31 @@
package gamedata
func LoadOwnedEventItemDesign(root, version string) (map[uint64]map[uint64]bool, error) {
db, done, e := openStatDatabase(root, version)
if e != nil {
return nil, e
}
defer done()
out := map[uint64]map[uint64]bool{}
for typ, table := range map[uint64]string{47: "IdCardItemTable", 49: "AvatarItemTable"} {
rows, e := db.Query("SELECT id FROM " + table)
if e != nil {
return nil, e
}
out[typ] = map[uint64]bool{}
for rows.Next() {
var id uint64
if e = rows.Scan(&id); e != nil {
rows.Close()
return nil, e
}
out[typ][id] = true
}
if e = rows.Err(); e != nil {
rows.Close()
return nil, e
}
rows.Close()
}
return out, nil
}
+101
View File
@@ -0,0 +1,101 @@
package gamedata
import (
"fmt"
"sort"
)
// EventPlayCatalog keeps the installed static rows for event gameplay.
type EventPlayCatalog struct {
Tables map[string][][]byte
root, version string
}
func LoadEventPlayCatalog(root, version string) (*EventPlayCatalog, error) {
db, done, e := openStatDatabase(root, version)
if e != nil {
return nil, e
}
defer done()
c := &EventPlayCatalog{Tables: map[string][][]byte{}, root: root, version: version}
names := []string{"PackEventHubTable", "PackEventListTable", "PackEventStoryGroupTable", "PackEventStoryTable", "PackEventBattleGroupTable", "PackEventBattleTable", "PackEventMiniGameTable", "FieldMiniGameRewardTable", "FieldMiniGameSpeedTable", "RhythmGameMusicTable", "RhythmGameGradeTable", "SichuanStageTable", "SichuanEventTable", "SichuanRewardTable", "HopscotchStageTable", "HopscotchRewardTable", "HopscotchDefaultTable", "FieldMiniGameSurvivalTable", "ActionGameDefaultTable", "ActionGameMissionTable", "ActionGameStageTable", "FieldMiniGameCharTable", "FieldMiniGameMapTable", "FieldMiniGameUpgradeTable", "FieldMiniGameUpgradeGroupTable", "FieldMiniGameSkillGroupTable", "FieldMiniGameSkillTable", "FieldMiniGameCharLevelTable", "FieldMiniGameMonsterTable", "FieldMiniGameSurvivalItemTable", "FieldMiniGameSurvivalBoxTable", "MGDRewardTable", "MGDWaveTable", "MGDDefaultTable"}
for _, name := range names {
rows, e := db.Query("SELECT ProtoBuf FROM " + name)
if e != nil {
return nil, e
}
for rows.Next() {
var raw []byte
if e = rows.Scan(&raw); e != nil {
rows.Close()
return nil, e
}
c.Tables[name] = append(c.Tables[name], append([]byte(nil), raw...))
}
e = rows.Err()
rows.Close()
if e != nil {
return nil, e
}
}
return c, nil
}
func (c *EventPlayCatalog) Row(table string, n int, id uint64) ([]byte, error) {
for _, r := range c.Tables[table] {
v, e := optionalScalar(r, n)
if e != nil {
return nil, e
}
if v == id {
return r, nil
}
}
return nil, fmt.Errorf("gamedata: %s row %d missing", table, id)
}
func (c *EventPlayCatalog) Rows(table string, n int, id uint64) [][]byte {
var out [][]byte
for _, r := range c.Tables[table] {
v, _ := optionalScalar(r, n)
if v == id {
out = append(out, r)
}
}
return out
}
func EventPlayRewards(raw []byte, typ, id, count int) ([]BattleReward, error) {
return eventGameRewards(raw, typ, id, count)
}
func (c *EventPlayCatalog) Story(group, id uint64) ([]byte, error) {
for _, r := range c.Rows("PackEventStoryTable", 1, group) {
v, _ := optionalScalar(r, 2)
if v == id {
return r, nil
}
}
return nil, fmt.Errorf("gamedata: missing event story")
}
func (c *EventPlayCatalog) ScoreRewards(group, score uint64) (uint64, []BattleReward, error) {
var picked []byte
point := uint64(0)
rows := c.Rows("FieldMiniGameRewardTable", 1, group)
sort.Slice(rows, func(i, j int) bool {
a, _ := optionalScalar(rows[i], 3)
b, _ := optionalScalar(rows[j], 3)
return a < b
})
for _, r := range rows {
p, e := optionalScalar(r, 3)
if e != nil {
return 0, nil, e
}
if p <= score && p >= point {
picked = r
point = p
}
}
if picked == nil {
return 0, nil, nil
}
rewards, e := eventGameRewards(picked, 6, 5, 4)
return point, rewards, e
}
@@ -0,0 +1,48 @@
package gamedata
import "fmt"
type EventField struct {
HP, Limit uint64
Objects map[uint64]struct{ Point, Type uint64 }
}
func (c *EventPlayCatalog) Field(game uint64) (*EventField, error) {
g, e := c.Row("PackEventMiniGameTable", 8, game)
if e != nil {
return nil, e
}
pack, _ := optionalScalar(g, 12)
target, _ := optionalScalar(g, 4)
db, done, e := openPackDatabase(c.root, c.version, int(pack))
if e != nil {
return nil, e
}
defer done()
var raw []byte
if e = db.QueryRow("SELECT ProtoBuf FROM FieldMiniGameTable WHERE id=?", target).Scan(&raw); e != nil {
return nil, e
}
group, _ := optionalScalar(raw, 3)
d := &EventField{Objects: map[uint64]struct{ Point, Type uint64 }{}}
d.HP, _ = optionalScalar(raw, 6)
d.Limit, _ = optionalScalar(raw, 7)
rows, e := db.Query("SELECT ProtoBuf FROM FieldMiniGameObjectTable WHERE groupId=? ORDER BY id", group)
if e != nil {
return nil, e
}
defer rows.Close()
for rows.Next() {
if e = rows.Scan(&raw); e != nil {
return nil, e
}
id, _ := optionalScalar(raw, 2)
point, _ := optionalScalar(raw, 3)
typ, _ := optionalScalar(raw, 4)
d.Objects[id] = struct{ Point, Type uint64 }{point, typ}
}
if len(d.Objects) == 0 {
return nil, fmt.Errorf("gamedata: field game objects missing")
}
return d, rows.Err()
}
@@ -0,0 +1,22 @@
package gamedata
import (
"os"
"testing"
)
func TestEventPlayInstalledCatalog(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not set")
}
c, e := LoadEventPlayCatalog(root, "20260923193640")
if e != nil {
t.Fatal(e)
}
for _, n := range []string{"PackEventStoryTable", "FieldMiniGameSkillTable", "MGDRewardTable"} {
if len(c.Tables[n]) == 0 {
t.Fatalf("empty %s", n)
}
}
}
+233
View File
@@ -0,0 +1,233 @@
package gamedata
import (
"database/sql"
"fmt"
"sort"
)
type EventAttendance struct{ ID, Group, Ticket uint64 }
type EventAttendanceGroup struct{ Group, ID, Next uint64 }
type EventAttendanceReward struct {
Group, ID, Day uint64
Basic, Premium Reward
}
type EventMissionGroup struct {
ID, Type, ScheduleType, UsePass, CompleteHide uint64
Groups []uint64
}
type EventTask struct {
ID, Group, Type, SubType, Target, PassExp, UnlockPack, UnlockQuest uint64
Params []uint64
Rewards []Reward
}
type EventPass struct {
ID, MissionGroup, LevelGroup, Type, ScheduleType, NewbieGroup, NewbieStep uint64
Core Reward
}
type EventPassLevel struct {
ID, NeedExp, PremiumType uint64
Basic, Premium Reward
}
type EventPassBuy struct {
ID, Type, UnlockLevel, CashGroup, CashID, CashSales uint64
Cost Reward
Rewards []Reward
}
type EventTasksDesign struct {
Attendance map[uint64]EventAttendance
AttendanceGroups map[[2]uint64]EventAttendanceGroup
AttendanceRewards map[uint64][]EventAttendanceReward
LimitRewards map[[2]uint64]uint64
MissionGroups map[uint64]EventMissionGroup
Missions map[uint64]EventTask
Passes map[uint64]EventPass
PassLevels map[uint64][]EventPassLevel
PassBuys map[uint64][]EventPassBuy
}
func LoadEventTasksDesign(root, version string) (*EventTasksDesign, error) {
db, done, e := openStatDatabase(root, version)
if e != nil {
return nil, e
}
defer done()
return loadEventTasksDesign(db)
}
func loadEventTasksDesign(db *sql.DB) (*EventTasksDesign, error) {
d := &EventTasksDesign{Attendance: map[uint64]EventAttendance{}, AttendanceGroups: map[[2]uint64]EventAttendanceGroup{}, AttendanceRewards: map[uint64][]EventAttendanceReward{}, LimitRewards: map[[2]uint64]uint64{}, MissionGroups: map[uint64]EventMissionGroup{}, Missions: map[uint64]EventTask{}, Passes: map[uint64]EventPass{}, PassLevels: map[uint64][]EventPassLevel{}, PassBuys: map[uint64][]EventPassBuy{}}
each := func(table string, fn func([]byte) error) error {
rows, e := db.Query("SELECT ProtoBuf FROM " + table)
if e != nil {
return e
}
defer rows.Close()
for rows.Next() {
var p []byte
if e = rows.Scan(&p); e != nil {
return e
}
if e = fn(p); e != nil {
return fmt.Errorf("gamedata: %s: %w", table, e)
}
}
return rows.Err()
}
scalar := func(p []byte, n int) (uint64, error) { return optionalScalar(p, n) }
read := func(p []byte, m map[int]*uint64) error {
for n, target := range m {
v, e := scalar(p, n)
if e != nil {
return e
}
*target = v
}
return nil
}
reward := func(p []byte, t, i, c int) (Reward, error) {
r := Reward{}
e := read(p, map[int]*uint64{t: &r.Type, i: &r.ID, c: &r.Count})
return r, e
}
if e := each("AttendanceAlwaysTable", func(p []byte) error {
r := EventAttendance{}
e := read(p, map[int]*uint64{3: &r.ID, 1: &r.Group, 2: &r.Ticket})
d.Attendance[r.ID] = r
return e
}); e != nil {
return nil, e
}
if e := each("AttendanceAlwaysGroupTable", func(p []byte) error {
r := EventAttendanceGroup{}
e := read(p, map[int]*uint64{1: &r.Group, 2: &r.ID, 3: &r.Next})
d.AttendanceGroups[[2]uint64{r.Group, r.ID}] = r
return e
}); e != nil {
return nil, e
}
if e := each("AttendanceAlwaysRewardTable", func(p []byte) error {
r := EventAttendanceReward{}
if e := read(p, map[int]*uint64{2: &r.Group, 3: &r.ID, 1: &r.Day}); e != nil {
return e
}
var e error
r.Basic, e = reward(p, 9, 8, 7)
if e != nil {
return e
}
r.Premium, e = reward(p, 6, 5, 4)
d.AttendanceRewards[r.Group] = append(d.AttendanceRewards[r.Group], r)
return e
}); e != nil {
return nil, e
}
if e := each("AttendanceLimitRewardTable", func(p []byte) error {
var g, i, b uint64
e := read(p, map[int]*uint64{1: &g, 2: &i, 3: &b})
d.LimitRewards[[2]uint64{g, i}] = b
return e
}); e != nil {
return nil, e
}
if e := each("EventMissionGroupTable", func(p []byte) error {
r := EventMissionGroup{}
if e := read(p, map[int]*uint64{5: &r.ID, 1: &r.Type, 8: &r.ScheduleType, 10: &r.UsePass, 6: &r.CompleteHide}); e != nil {
return e
}
var e error
r.Groups, e = packedInts(p, 7)
d.MissionGroups[r.ID] = r
return e
}); e != nil {
return nil, e
}
if e := each("MissionTable", func(p []byte) error {
g, e := scalar(p, 7)
if e != nil {
return e
}
if g != 2 {
return nil
}
r := EventTask{}
if e = read(p, map[int]*uint64{8: &r.ID, 6: &r.Group, 3: &r.Type, 1: &r.SubType, 4: &r.Target, 12: &r.PassExp, 19: &r.UnlockPack, 20: &r.UnlockQuest}); e != nil {
return e
}
r.Params, e = packedInts(p, 2)
if e != nil {
return e
}
v, e := reward(p, 15, 14, 13)
if e != nil {
return e
}
if v.Type > 0 && v.Count > 0 {
r.Rewards = []Reward{v}
}
d.Missions[r.ID] = r
return nil
}); e != nil {
return nil, e
}
if e := each("PassTable", func(p []byte) error {
r := EventPass{}
if e := read(p, map[int]*uint64{6: &r.ID, 5: &r.MissionGroup, 11: &r.LevelGroup, 13: &r.Type, 15: &r.ScheduleType, 9: &r.NewbieGroup, 10: &r.NewbieStep}); e != nil {
return e
}
var e error
r.Core, e = reward(p, 4, 3, 2)
d.Passes[r.ID] = r
return e
}); e != nil {
return nil, e
}
if e := each("PassLevelTable", func(p []byte) error {
r := EventPassLevel{}
var g uint64
if e := read(p, map[int]*uint64{4: &g, 5: &r.ID, 6: &r.NeedExp, 10: &r.PremiumType}); e != nil {
return e
}
var e error
r.Basic, e = reward(p, 3, 2, 1)
if e != nil {
return e
}
r.Premium, e = reward(p, 9, 8, 7)
d.PassLevels[g] = append(d.PassLevels[g], r)
return e
}); e != nil {
return nil, e
}
if e := each("PassBuyTable", func(p []byte) error {
r := EventPassBuy{}
var g uint64
if e := read(p, map[int]*uint64{7: &g, 8: &r.ID, 12: &r.Type, 13: &r.UnlockLevel, 4: &r.CashGroup, 5: &r.CashID, 6: &r.CashSales}); e != nil {
return e
}
var e error
r.Cost, e = reward(p, 3, 2, 1)
if e != nil {
return e
}
rs, e := monsterHuntRewardArrays(p, 11, 10, 9)
if e != nil {
return e
}
for _, v := range rs {
r.Rewards = append(r.Rewards, Reward{v.Type, v.ID, v.Count})
}
d.PassBuys[g] = append(d.PassBuys[g], r)
return nil
}); e != nil {
return nil, e
}
for g, rs := range d.AttendanceRewards {
sort.Slice(rs, func(i, j int) bool { return rs[i].ID < rs[j].ID })
d.AttendanceRewards[g] = rs
}
for g, rs := range d.PassLevels {
sort.Slice(rs, func(i, j int) bool { return rs[i].ID < rs[j].ID })
d.PassLevels[g] = rs
}
return d, nil
}
@@ -0,0 +1,27 @@
package gamedata
import (
"os"
"testing"
)
func TestInstalledEventTasks23510(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
d, e := LoadEventTasksDesign(root, "20260923193640")
if e != nil {
t.Fatal(e)
}
if len(d.Attendance) != 109 || len(d.MissionGroups) != 519 || len(d.Passes) != 141 {
t.Fatal("event design tables not fully loaded")
}
p := d.Passes[141]
if p.MissionGroup != 51 || p.LevelGroup != 141 || d.PassLevels[141][0].NeedExp != 20 || d.PassLevels[141][0].Basic.Type != 9 {
t.Fatalf("pass 141 mismatch %+v", p)
}
if d.MissionGroups[51].Type != 3 || len(d.MissionGroups[51].Groups) != 7 {
t.Fatal("daily-open groups lost")
}
}
+2 -17
View File
@@ -3,8 +3,6 @@ package gamedata
import (
"database/sql"
"fmt"
"os"
"path/filepath"
)
// FieldObjectDesign retains the client table identities, including unsupported
@@ -21,24 +19,11 @@ type FieldRewardObject struct {
}
func LoadFieldObjects(root, version string, pack int) (FieldObjectDesign, error) {
plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", pack))
db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", pack))
if err != nil {
return FieldObjectDesign{}, err
}
dir, err := os.MkdirTemp("", "bd2-field-objects-")
if err != nil {
return FieldObjectDesign{}, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "pack.db")
if err = os.WriteFile(path, plain, 0600); err != nil {
return FieldObjectDesign{}, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return FieldObjectDesign{}, err
}
defer db.Close()
defer release()
common, closeDB, err := openStatDatabase(root, version)
if err != nil {
return FieldObjectDesign{}, err
@@ -0,0 +1,225 @@
package gamedata
import (
"bd2server/internal/server/wire"
"database/sql"
"fmt"
)
type FieldResearchObject struct {
ID, Type, CollectionID, HoldingCollectionID, TalentObjectID int
Maps []int
QuestRange []uint64
InteractionQuests []int
Reward Reward
}
type FieldResearchDesign struct{ Objects map[int]FieldResearchObject }
func openLogicalReadDB(root, version, name string) (*sql.DB, func(), error) {
return OpenDatabase(root, version, name)
}
func LoadFieldResearch(root, version string, pack int) (FieldResearchDesign, error) {
d := FieldResearchDesign{Objects: map[int]FieldResearchObject{}}
db, done, err := openLogicalReadDB(root, version, fmt.Sprintf("pack%d", pack))
if err != nil {
return d, err
}
defer done()
rows, err := db.Query("SELECT ProtoBuf FROM FieldResearchObjectTable")
if err != nil {
return d, err
}
for rows.Next() {
var raw []byte
if err = rows.Scan(&raw); err != nil {
rows.Close()
return d, err
}
var o FieldResearchObject
values := []*int{&o.CollectionID, &o.HoldingCollectionID, &o.ID, &o.TalentObjectID, &o.Type}
for i, f := range []int{1, 3, 5, 17, 18} {
v, e := optionalScalar(raw, f)
if e != nil {
rows.Close()
return d, e
}
*values[i] = int(v)
}
o.QuestRange, err = packedInts(raw, 10)
if err != nil {
rows.Close()
return d, err
}
o.Reward.Type, err = optionalScalar(raw, 16)
if err != nil {
rows.Close()
return d, err
}
o.Reward.ID, err = optionalScalar(raw, 15)
if err != nil {
rows.Close()
return d, err
}
o.Reward.Count, err = optionalScalar(raw, 14)
if err != nil {
rows.Close()
return d, err
}
if o.ID <= 0 {
rows.Close()
return d, fmt.Errorf("gamedata: invalid research id")
}
d.Objects[o.ID] = o
}
err = rows.Err()
rows.Close()
if err != nil {
return d, err
}
common, closeCommon, err := openStatDatabase(root, version)
if err != nil {
return d, err
}
defer closeCommon()
maps := map[string]int{}
rows, err = common.Query("SELECT id,mapScenePath FROM MapTable WHERE packId=?", pack)
if err != nil {
return d, err
}
for rows.Next() {
var id int
var scene string
if err = rows.Scan(&id, &scene); err != nil {
rows.Close()
return d, err
}
maps[scene] = id
}
err = rows.Err()
rows.Close()
if err != nil {
return d, err
}
scenes, closeScenes, err := openLogicalReadDB(root, version, "FieldObjectSceneData")
if err != nil {
return d, err
}
defer closeScenes()
rows, err = scenes.Query(fmt.Sprintf("SELECT ProtoBuf FROM FieldObjectSceneData%d WHERE objectType=5", pack))
if err != nil {
return d, err
}
defer rows.Close()
for rows.Next() {
var raw []byte
if err = rows.Scan(&raw); err != nil {
return d, err
}
id, e := optionalScalar(raw, 8)
if e != nil {
return d, e
}
var scene string
if e = wire.Walk(raw, func(f wire.Field) error {
if f.Number == 7 && f.Type == 2 {
scene = string(f.Value)
}
return nil
}); e != nil {
return d, e
}
o, ok := d.Objects[int(id)]
if !ok {
return d, fmt.Errorf("gamedata: scene references missing research %d", id)
}
mapID, ok := maps[scene]
if !ok {
return d, fmt.Errorf("gamedata: research scene %s missing map", scene)
}
o.Maps = append(o.Maps, mapID)
d.Objects[o.ID] = o
}
if err = rows.Err(); err != nil {
return d, err
}
rows.Close()
rows, err = common.Query(fmt.Sprintf("SELECT id,ProtoBuf FROM QuestTable%d", pack))
if err != nil {
return d, err
}
defer rows.Close()
for rows.Next() {
var id int
var raw []byte
if err = rows.Scan(&id, &raw); err != nil {
return d, err
}
condition, e := optionalScalar(raw, 11)
if e != nil {
return d, e
}
if condition != 18 {
continue
}
objects, e := packedInts(raw, 33)
if e != nil {
return d, e
}
for _, object := range objects {
if o, ok := d.Objects[int(object)]; ok {
o.InteractionQuests = append(o.InteractionQuests, id)
d.Objects[o.ID] = o
}
}
}
return d, rows.Err()
}
// Research characters are identified by the client's FieldRewardResearch class
// (6), joined through CharTable's talent ID rather than a fixed character list.
func LoadResearchCharacters(root, version string) (map[uint64]bool, error) {
db, done, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer done()
talents := map[uint64]bool{}
rows, err := db.Query("SELECT id FROM TalentTable WHERE classType=6")
if err != nil {
return nil, err
}
for rows.Next() {
var id uint64
if err = rows.Scan(&id); err != nil {
rows.Close()
return nil, err
}
talents[id] = true
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
chars := map[uint64]bool{}
rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id uint64
var raw []byte
if err = rows.Scan(&id, &raw); err != nil {
return nil, err
}
talent, e := optionalScalar(raw, 18)
if e != nil {
return nil, e
}
if talents[talent] {
chars[id] = true
}
}
return chars, rows.Err()
}
@@ -0,0 +1,28 @@
package gamedata
import (
"os"
"testing"
)
func TestInstalledFieldResearch23510(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
d, err := LoadFieldResearch(root, "20260923193640", 1)
if err != nil {
t.Fatal(err)
}
o := d.Objects[401]
if o.CollectionID != 103 || o.Type != 1 || o.Reward.Type != 4 || o.Reward.Count != 345 || len(o.Maps) != 1 || o.Maps[0] != 4 {
t.Fatalf("research table mismatch: %+v", o)
}
chars, err := LoadResearchCharacters(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
if len(chars) == 0 {
t.Fatal("research talents empty")
}
}
+2 -17
View File
@@ -5,8 +5,6 @@ import (
"errors"
"fmt"
"math"
"os"
"path/filepath"
)
// Food is decoded from FoodTable, including materials so that the server can
@@ -19,24 +17,11 @@ type Food struct {
type FoodDesign struct{ Foods map[uint64]Food }
func LoadFoodDesign(root, version string) (*FoodDesign, error) {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-food-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
return loadFoodDesign(db)
}
+26 -59
View File
@@ -8,8 +8,6 @@ import (
"fmt"
"math"
"math/big"
"os"
"path/filepath"
"sort"
_ "modernc.org/sqlite"
@@ -971,24 +969,11 @@ func isPermanentEquipmentGachaGroup(raw []byte) bool {
// selected by a captured schedule. Resemara is owned by LoadInfiniteGacha and
// is intentionally omitted from both ordinary catalogs.
func ClassifyActiveGachaGroups(root, version string, groupIDs []uint64) (costume, equipment []uint64, err error) {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, nil, err
}
dir, err := os.MkdirTemp("", "bd2-gacha-classify-")
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()
defer release()
seen := map[uint64]bool{}
for _, id := range groupIDs {
if seen[id] {
@@ -1026,24 +1011,11 @@ func ClassifyActiveGachaGroups(root, version string, groupIDs []uint64) (costume
}
func LoadRegularCostumeGachaGroups(root, version string, groupIDs, stepUpGroupIDs []uint64) (*RegularGachaCatalog, error) {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-regular-gacha-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
catalog := &RegularGachaCatalog{
Gachas: map[uint64]RegularGacha{}, characters: make(map[uint64]CharacterDesign),
groups: map[uint64]GachaGroupDesign{}, byGacha: map[uint64]uint64{}, fixed: map[uint64]GachaFixedDesign{}, grades: map[uint64]uint64{},
@@ -1218,6 +1190,15 @@ func loadGachaStepUp(db *sql.DB, groupID uint64) (GachaStepUpDesign, error) {
if id != step.Step || step.Step != uint64(len(out.Steps)+1) || step.GroupID == 0 || step.GachaID == 0 {
return GachaStepUpDesign{}, fmt.Errorf("gamedata: malformed step-up group %d row %d", groupID, id)
}
out.Steps = append(out.Steps, step)
}
if err := rows.Err(); err != nil {
return GachaStepUpDesign{}, err
}
if err := rows.Close(); err != nil {
return GachaStepUpDesign{}, err
}
for _, step := range out.Steps {
var child []byte
if err := db.QueryRow("SELECT ProtoBuf FROM GachaGroupTable WHERE id=?", step.GroupID).Scan(&child); err != nil {
return GachaStepUpDesign{}, fmt.Errorf("gamedata: step-up child group %d: %w", step.GroupID, err)
@@ -1228,10 +1209,6 @@ func loadGachaStepUp(db *sql.DB, groupID uint64) (GachaStepUpDesign, error) {
if len(types) != 1 || types[0] != 1 || len(subTypes) != 1 || subTypes[0] != 4 || len(tenIDs) != 1 || tenIDs[0] != step.GachaID {
return GachaStepUpDesign{}, fmt.Errorf("gamedata: step-up group %d child %d does not reference gacha %d", groupID, step.GroupID, step.GachaID)
}
out.Steps = append(out.Steps, step)
}
if err := rows.Err(); err != nil {
return GachaStepUpDesign{}, err
}
if len(out.Steps) == 0 {
return GachaStepUpDesign{}, fmt.Errorf("gamedata: step-up group %d is empty", groupID)
@@ -1398,13 +1375,6 @@ func (c *RegularGachaCatalog) loadGroupsAndFixed(db *sql.DB) error {
if !oneLoaded && !tenLoaded {
continue
}
if group.CashProductGroupID != 0 && group.CashProductID != 0 {
var err error
group.CashRewards, err = loadGachaCashRewards(db, group)
if err != nil {
return err
}
}
c.groups[id] = group
if oneLoaded {
c.byGacha[group.OneTimeGachaID] = id
@@ -1416,7 +1386,17 @@ func (c *RegularGachaCatalog) loadGroupsAndFixed(db *sql.DB) error {
if err := rows.Err(); err != nil {
return err
}
for _, group := range c.groups {
if err := rows.Close(); err != nil {
return err
}
for id, group := range c.groups {
if group.CashProductGroupID != 0 && group.CashProductID != 0 {
group.CashRewards, err = loadGachaCashRewards(db, group)
if err != nil {
return err
}
c.groups[id] = group
}
if group.FixedID == 0 {
continue
}
@@ -1658,24 +1638,11 @@ func NewInfiniteGachaDesignWithRates(count int, fiveStarIDs, fourStarIDs, threeS
}
func LoadInfiniteGachaForSchedules(root, version string, groups []uint64) (*InfiniteGachaDesign, error) {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-gacha-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
infiniteGroupID, infiniteGachaID, err := loadInfiniteGachaIdentity(db, groups)
if err != nil {
@@ -58,11 +58,21 @@ func loadInfiniteCashIdentity(db *sql.DB, rewardID uint64) (groupID, productID,
return 0, 0, 0, err
}
defer rows.Close()
var products [][]byte
for rows.Next() {
var raw []byte
if err = rows.Scan(&raw); err != nil {
return
}
products = append(products, raw)
}
if err = rows.Err(); err != nil {
return
}
if err = rows.Close(); err != nil {
return
}
for _, raw := range products {
box, e := optionalScalar(raw, 14)
if e != nil {
return 0, 0, 0, e
+2 -17
View File
@@ -3,8 +3,6 @@ package gamedata
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sort"
_ "modernc.org/sqlite"
@@ -19,24 +17,11 @@ func CharacterGrowth(root, version string, charID int, level, exp uint64, materi
if charID <= 0 || level == 0 || len(materials) == 0 {
return 0, 0, nil, fmt.Errorf("gamedata: invalid character growth input")
}
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return 0, 0, nil, err
}
dir, err := os.MkdirTemp("", "bd2-growth-")
if err != nil {
return 0, 0, nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return 0, 0, nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return 0, 0, nil, err
}
defer db.Close()
defer release()
var charProto []byte
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(&charProto); err != nil {
+50
View File
@@ -0,0 +1,50 @@
package gamedata
import (
"bd2server/internal/server/wire"
"fmt"
"strconv"
"strings"
)
type HuntingAPDesign struct {
Max uint64
ResetSeconds int64
}
func LoadHuntingAPDesign(root, version string) (HuntingAPDesign, error) {
db, done, e := openStatDatabase(root, version)
if e != nil {
return HuntingAPDesign{}, e
}
defer done()
var raw []byte
if e = db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&raw); e != nil {
return HuntingAPDesign{}, e
}
max, e := optionalScalar(raw, 56)
if e != nil {
return HuntingAPDesign{}, e
}
text, _, e := wire.Bytes(raw, 23)
if e != nil {
return HuntingAPDesign{}, e
}
parts := strings.Split(string(text), ":")
if max == 0 || max > 2147483647 || len(parts) != 3 {
return HuntingAPDesign{}, fmt.Errorf("gamedata: invalid hunting AP defaults")
}
var secs int64
for i, p := range parts {
n, e := strconv.ParseInt(p, 10, 64)
limit := int64(60)
if i == 0 {
limit = 24
}
if e != nil || n < 0 || n >= limit {
return HuntingAPDesign{}, fmt.Errorf("gamedata: invalid daily reset time")
}
secs = secs*60 + n
}
return HuntingAPDesign{Max: max, ResetSeconds: secs}, nil
}
+2 -17
View File
@@ -4,8 +4,6 @@ import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
@@ -28,24 +26,11 @@ type InventorySlotDesign struct {
}
func LoadInventorySlotDesign(root, version string) (*InventorySlotDesign, error) {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-inventory-slots-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
return loadInventorySlotDesign(db)
}
+13 -23
View File
@@ -1,11 +1,8 @@
package gamedata
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
@@ -19,24 +16,11 @@ type LimitedCostumeCatalog struct {
}
func LoadLimitedCostumes(root, version string) (*LimitedCostumeCatalog, error) {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-limited-costumes-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
rows, err := db.Query("SELECT id FROM LimitedCostumeTable ORDER BY id")
if err != nil {
return nil, err
@@ -51,19 +35,25 @@ func LoadLimitedCostumes(root, version string) (*LimitedCostumeCatalog, error) {
if costumeID == 0 {
return nil, errors.New("gamedata: limited costume has zero id")
}
design, err := loadGachaCharacterDesign(db, costumeID)
if err != nil {
return nil, fmt.Errorf("gamedata: limited costume %d: %w", costumeID, err)
}
catalog.ids = append(catalog.ids, costumeID)
catalog.characters[costumeID] = design
}
if err := rows.Err(); err != nil {
return nil, err
}
// Release the single connection before character loading performs queries.
if err := rows.Close(); err != nil {
return nil, err
}
if len(catalog.ids) == 0 {
return nil, errors.New("gamedata: limited costume catalog is empty")
}
for _, costumeID := range catalog.ids {
design, err := loadGachaCharacterDesign(db, costumeID)
if err != nil {
return nil, fmt.Errorf("gamedata: limited costume %d: %w", costumeID, err)
}
catalog.characters[costumeID] = design
}
return catalog, nil
}
@@ -0,0 +1,41 @@
package gamedata
import (
"os"
"testing"
"time"
)
func TestRealLimitedCostumesReleasesRowsBeforeCharacterQueries(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
type result struct {
catalog *LimitedCostumeCatalog
err error
}
done := make(chan result, 1)
go func() { c, e := LoadLimitedCostumes(root, "20260923193640"); done <- result{c, e} }()
select {
case r := <-done:
if r.err != nil {
t.Fatal(r.err)
}
ids := r.catalog.IDs()
if len(ids) == 0 {
t.Fatal("empty limited costume catalog")
}
for i, id := range ids {
if i > 0 && ids[i-1] >= id {
t.Fatal("unstable/duplicate membership order")
}
design, ok := r.catalog.Character(id)
if !ok || design.ID == 0 {
t.Fatalf("limited costume %d missing character design", id)
}
}
case <-time.After(15 * time.Second):
t.Fatal("limited costume nested queries blocked on retained result rows")
}
}
@@ -0,0 +1,45 @@
package gamedata
import (
"fmt"
"sort"
)
type LoginPassReward struct {
ID, TicketID uint64
Free, Premium Reward
}
type LoginPassCatalog struct{ Groups map[uint64][]LoginPassReward }
func LoadLoginPassCatalog(root, version string) (*LoginPassCatalog, error) {
db, done, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer done()
d := &LoginPassCatalog{Groups: map[uint64][]LoginPassReward{}}
err = readCashMetadata(db, "LoginPassTable", func(raw []byte) error {
v, e := cashScalars(raw, 5, 6, 1, 4, 3, 2, 9, 8, 7)
if e != nil {
return e
}
if v[0] == 0 || v[1] == 0 || v[2] == 0 || v[3] == 0 || v[5] == 0 || v[6] == 0 || v[8] == 0 {
return fmt.Errorf("gamedata: invalid login-pass reward")
}
d.Groups[v[0]] = append(d.Groups[v[0]], LoginPassReward{ID: v[1], TicketID: v[2], Free: Reward{Type: v[3], ID: v[4], Count: v[5]}, Premium: Reward{Type: v[6], ID: v[7], Count: v[8]}})
return nil
})
if err != nil {
return nil, err
}
for group, rows := range d.Groups {
sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID })
for i, r := range rows {
if r.ID != uint64(i+1) || r.TicketID != rows[0].TicketID {
return nil, fmt.Errorf("gamedata: invalid login-pass sequence %d", group)
}
}
d.Groups[group] = rows
}
return d, nil
}
+2 -17
View File
@@ -4,8 +4,6 @@ import (
"database/sql"
"fmt"
"math"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
@@ -56,24 +54,11 @@ type AchievementDesign struct {
// current database. Event missions intentionally remain outside this reader:
// their eligibility depends on an active server schedule, not just GameData.
func LoadMissionDesign(root, version string) (*MissionDesign, error) {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-mission-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
return loadMissionDesignDB(db)
}
+2 -17
View File
@@ -3,8 +3,6 @@ package gamedata
import (
"database/sql"
"fmt"
"os"
"path/filepath"
)
// PackDetailDesign identifies the monsters whose state is consumed by the
@@ -15,24 +13,11 @@ func LoadPackDetailDesign(root, version string, packID int) (PackDetailDesign, e
if packID <= 0 {
return PackDetailDesign{}, fmt.Errorf("gamedata: invalid detail pack %d", packID)
}
plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID))
db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", packID))
if err != nil {
return PackDetailDesign{}, err
}
dir, err := os.MkdirTemp("", "bd2-pack-detail-")
if err != nil {
return PackDetailDesign{}, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "pack.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return PackDetailDesign{}, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return PackDetailDesign{}, err
}
defer db.Close()
defer release()
return loadPackDetailDesign(db)
}
+2 -18
View File
@@ -1,10 +1,7 @@
package gamedata
import (
"database/sql"
"fmt"
"os"
"path/filepath"
)
// PackTransition is the authoritative story edge in PackTable. It is kept
@@ -18,24 +15,11 @@ func LoadPackTransition(root, version string, packID int) (PackTransition, error
if packID <= 0 {
return PackTransition{}, fmt.Errorf("gamedata: invalid pack transition id %d", packID)
}
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return PackTransition{}, err
}
dir, err := os.MkdirTemp("", "bd2-pack-transition-")
if err != nil {
return PackTransition{}, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return PackTransition{}, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return PackTransition{}, err
}
defer db.Close()
defer release()
var raw []byte
if err := db.QueryRow("SELECT ProtoBuf FROM PackTable WHERE id=?", packID).Scan(&raw); err != nil {
return PackTransition{}, fmt.Errorf("gamedata: pack %d: %w", packID, err)
+2 -17
View File
@@ -3,8 +3,6 @@ package gamedata
import (
"database/sql"
"fmt"
"os"
"path/filepath"
)
// PresetDesign is the normal party preset configuration in GameDefaultTable.
@@ -26,24 +24,11 @@ func (d PresetDesign) Validate() error {
}
func LoadPresetDesign(root, version string) (*PresetDesign, error) {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-presets-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
return loadPresetDesign(db)
}
+4 -32
View File
@@ -3,8 +3,6 @@ package gamedata
import (
"database/sql"
"fmt"
"os"
"path/filepath"
)
// PromotionCost is one exact CharGrowthTable.ClassupItem{Type,Id,Count} row.
@@ -26,24 +24,11 @@ type PromotionGrowthResult struct {
// stage cap the client sends its class-up materials through CharGrowth, then
// detects a promotion by comparing the response character ID to the old ID.
func CharacterPromotion(root, version string, charID int, level, exp uint64) (uint64, []PromotionCost, error) {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return 0, nil, err
}
dir, err := os.MkdirTemp("", "bd2-promotion-")
if err != nil {
return 0, nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return 0, nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return 0, nil, err
}
defer db.Close()
defer release()
return characterPromotion(db, charID, level, exp)
}
@@ -54,24 +39,11 @@ func CharacterPromotion(root, version string, charID int, level, exp uint64) (ui
// CharTable.NextCharId chain; each prefix cost is then subtracted from the
// submitted resources and only the remainder contributes EXP.
func CharacterGrowthPromotions(root, version string, charID int, level, exp uint64, submitted []PromotionCost) (PromotionGrowthResult, error) {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return PromotionGrowthResult{}, err
}
dir, err := os.MkdirTemp("", "bd2-promotion-growth-")
if err != nil {
return PromotionGrowthResult{}, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return PromotionGrowthResult{}, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return PromotionGrowthResult{}, err
}
defer db.Close()
defer release()
return characterGrowthPromotions(db, charID, level, exp, submitted)
}
@@ -13,6 +13,7 @@ func TestQuestCollectionsFollowChangedPackQuestAndItemIDs(t *testing.T) {
t.Fatal(err)
}
defer db.Close()
db.SetMaxOpenConns(1)
for _, sqlText := range []string{"CREATE TABLE QuestTable77(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE CollectionTable(id INTEGER,ProtoBuf BLOB)"} {
if _, err := db.Exec(sqlText); err != nil {
t.Fatal(err)
@@ -3,35 +3,16 @@ package gamedata
import (
"database/sql"
"fmt"
"os"
"path/filepath"
)
// LoadQuestDifficulties reads the pack-specific selectable difficulties;
// normal (zero) is the base story chain and is absent from this table.
func LoadQuestDifficulties(root, version string) (map[int]map[int]bool, error) {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
file, err := os.CreateTemp("", "bd2-quest-difficulty-*.sqlite")
if err != nil {
return nil, err
}
name := file.Name()
defer os.Remove(name)
if _, err = file.Write(plain); err != nil {
file.Close()
return nil, err
}
if err = file.Close(); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(name)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
return loadQuestDifficultiesDB(db)
}
func loadQuestDifficultiesDB(db *sql.DB) (map[int]map[int]bool, error) {
+37 -29
View File
@@ -3,8 +3,6 @@ package gamedata
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
@@ -48,29 +46,11 @@ func LoadQuestFormations(root, version string, packID int) (map[int]QuestFormati
if packID <= 0 {
return nil, fmt.Errorf("gamedata: invalid quest pack %d", packID)
}
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
f, err := os.CreateTemp("", "bd2-quest-formation-*.sqlite")
if err != nil {
return nil, fmt.Errorf("gamedata: create quest database: %w", err)
}
name := f.Name()
defer os.Remove(name)
if _, err = f.Write(plain); err == nil {
err = f.Close()
} else {
_ = f.Close()
}
if err != nil {
return nil, fmt.Errorf("gamedata: write quest database: %w", err)
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(name)+"?mode=ro")
if err != nil {
return nil, fmt.Errorf("gamedata: open quest database: %w", err)
}
defer db.Close()
defer release()
return loadQuestFormationsDB(db, packID)
}
@@ -87,12 +67,26 @@ func loadQuestFormationsDB(db *sql.DB, packID int) (map[int]QuestFormation, erro
formations := make(map[int]QuestFormation)
charGroups := make(map[uint64][]QuestCharacterDesign)
storyGroups := make(map[uint64][]QuestCostumeDesign)
type formationRow struct {
id int
proto []byte
}
var sourceRows []formationRow
for rows.Next() {
var questID int
var proto []byte
if err := rows.Scan(&questID, &proto); err != nil {
return nil, fmt.Errorf("gamedata: scan QuestTable%d formation: %w", packID, err)
var row formationRow
if err := rows.Scan(&row.id, &row.proto); err != nil {
return nil, err
}
sourceRows = append(sourceRows, row)
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
for _, row := range sourceRows {
questID, proto := row.id, row.proto
charGroupID, err := optionalScalar(proto, 4)
if err != nil {
return nil, fmt.Errorf("gamedata: quest %d char group: %w", questID, err)
@@ -188,12 +182,26 @@ func loadStoryCostumeGroup(db *sql.DB, groupID uint64) ([]QuestCostumeDesign, er
}
defer rows.Close()
var result []QuestCostumeDesign
type storyRow struct {
id int
proto []byte
}
var sourceRows []storyRow
for rows.Next() {
var rowID int
var proto []byte
if err := rows.Scan(&rowID, &proto); err != nil {
var row storyRow
if err := rows.Scan(&row.id, &row.proto); err != nil {
return nil, err
}
sourceRows = append(sourceRows, row)
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
for _, row := range sourceRows {
rowID, proto := row.id, row.proto
costumeID, err := requiredScalar(proto, 1)
if err != nil {
return nil, fmt.Errorf("story costume group %d row %d: %w", groupID, rowID, err)
@@ -17,6 +17,9 @@ func TestLoadQuestFormationsDB(t *testing.T) {
t.Fatal(err)
}
defer db.Close()
// Match production's single-connection database lease so nested formation
// and costume lookups require source rows to be released first.
db.SetMaxOpenConns(1)
for _, statement := range []string{
"CREATE TABLE QuestTable21 (id INTEGER PRIMARY KEY, ProtoBuf BLOB NOT NULL)",
"CREATE TABLE CharGroupTable (id INTEGER NOT NULL, GroupId INTEGER NOT NULL, ProtoBuf BLOB NOT NULL)",
+22 -21
View File
@@ -3,8 +3,6 @@ package gamedata
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
@@ -26,24 +24,11 @@ func LoadQuestDesign(root, version string, packID int) (map[int]QuestDesign, err
if packID <= 0 {
return nil, fmt.Errorf("gamedata: invalid quest pack %d", packID)
}
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-quests-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "quests.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
return loadQuestDesignDB(db, packID)
}
@@ -56,13 +41,29 @@ func loadQuestDesignDB(db *sql.DB, packID int) (map[int]QuestDesign, error) {
return nil, fmt.Errorf("gamedata: query QuestTable%d: %w", packID, err)
}
defer rows.Close()
result := make(map[int]QuestDesign)
type questRow struct {
id int
proto []byte
}
var questRows []questRow
for rows.Next() {
var id int
var proto []byte
if err := rows.Scan(&id, &proto); err != nil {
var row questRow
if err := rows.Scan(&row.id, &row.proto); err != nil {
return nil, err
}
questRows = append(questRows, row)
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
// Collection lookups reuse the database's single connection after the
// quest result set is closed.
result := make(map[int]QuestDesign)
for _, row := range questRows {
id, proto := row.id, row.proto
if id <= 0 {
continue
}
+2 -18
View File
@@ -1,11 +1,8 @@
package gamedata
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
@@ -24,24 +21,11 @@ type RandomBoxDesign struct {
// empty, weighted, malformed, cyclic, or resolves to a non-ItemDBInfo reward;
// callers then fail closed when a client tries to open it.
func LoadRandomBoxDesign(root, version string) (*RandomBoxDesign, error) {
plain, err := ReadQuestDatabase(root, version)
db, release, err := OpenDatabase(root, version, "common")
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-random-box-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
defer release()
groups := map[uint64][]BattleReward{}
rows, err := db.Query("SELECT id, ProtoBuf FROM RewardGroupTable")
+2 -17
View File
@@ -4,8 +4,6 @@ import (
"database/sql"
"fmt"
"math"
"os"
"path/filepath"
)
type RecruitNPC struct {
@@ -20,24 +18,11 @@ func LoadRecruitNPC(root, version string, packID int, npcID uint64) (RecruitNPC,
if packID <= 0 || npcID == 0 || npcID > math.MaxInt32 {
return RecruitNPC{}, fmt.Errorf("gamedata: invalid recruit NPC")
}
plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID))
db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", packID))
if err != nil {
return RecruitNPC{}, err
}
dir, err := os.MkdirTemp("", "bd2-recruit-npc-")
if err != nil {
return RecruitNPC{}, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "pack.db")
if err = os.WriteFile(path, plain, 0o600); err != nil {
return RecruitNPC{}, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return RecruitNPC{}, err
}
defer db.Close()
defer release()
npc, err := loadRecruitNPC(db, npcID)
if err != nil {
return RecruitNPC{}, err
@@ -0,0 +1,73 @@
package gamedata
// LoadRewardCostumeCatalog covers every installed costume, including event-only
// rewards that never occur in a regular gacha pool.
func LoadRewardCostumeCatalog(root, version string) (*RegularGachaCatalog, error) {
db, done, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer done()
chars, e := db.Query("SELECT uniqueCharId,ProtoBuf FROM CharTable")
if e != nil {
return nil, e
}
eligible := map[uint64]uint64{}
for chars.Next() {
var unique uint64
var raw []byte
if e = chars.Scan(&unique, &raw); e != nil {
chars.Close()
return nil, e
}
growth, e := optionalScalar(raw, 10)
if e != nil {
chars.Close()
return nil, e
}
temporary, e := optionalScalar(raw, 21)
if e != nil {
chars.Close()
return nil, e
}
if growth == 1 && temporary == 0 {
eligible[unique]++
}
}
if e = chars.Err(); e != nil {
chars.Close()
return nil, e
}
chars.Close()
rows, err := db.Query("SELECT id,useUniqueCharId FROM CostumeTable ORDER BY id")
if err != nil {
return nil, err
}
var ids []uint64
for rows.Next() {
var id, unique uint64
if err = rows.Scan(&id, &unique); err != nil {
rows.Close()
return nil, err
}
if eligible[unique] == 1 {
ids = append(ids, id)
}
}
if err = rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
out := &RegularGachaCatalog{characters: map[uint64]CharacterDesign{}}
for _, id := range ids {
design, e := loadGachaCharacterDesign(db, id)
if e != nil {
// Non-player prototype costumes lack a grantable growth boundary.
// They remain unavailable to Economy rather than inventing metadata.
continue
}
out.characters[id] = design
}
return out, nil
}
@@ -0,0 +1,24 @@
package gamedata
import (
"os"
"testing"
)
func TestRewardCostumeCatalogInstalledSmoke(t *testing.T) {
root := os.Getenv("BD2_GAMEDATA_ROOT")
version := os.Getenv("BD2_GAMEDATA_VERSION")
if root == "" || version == "" {
t.Skip("explicit installed GameData integration environment required")
}
d, e := LoadRewardCostumeCatalog(root, version)
if e != nil {
t.Fatal(e)
}
if len(d.characters) == 0 {
t.Fatal("empty catalog")
}
if _, ok := d.Character(60601); !ok {
t.Fatal("installed event costume omitted")
}
}
@@ -0,0 +1,38 @@
package gamedata
// LoadRewardEquipmentCatalog reads every equipment definition for rewards,
// independently of which gacha banners the server has opened.
func LoadRewardEquipmentCatalog(root, version string) (*EquipmentGachaCatalog, error) {
db, done, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer done()
rows, err := db.Query("SELECT id FROM EquipmentTable ORDER BY id")
if err != nil {
return nil, err
}
var ids []uint64
for rows.Next() {
var id uint64
if err = rows.Scan(&id); err != nil {
rows.Close()
return nil, err
}
ids = append(ids, id)
}
if err = rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err = rows.Close(); err != nil {
return nil, err
}
c := &EquipmentGachaCatalog{equipment: map[uint64]EquipmentDesign{}}
for _, id := range ids {
if err = c.loadEquipmentTree(db, WeightedEquipment{ID: id}); err != nil {
return nil, err
}
}
return c, nil
}
@@ -0,0 +1,25 @@
package gamedata
import (
"os"
"testing"
)
func TestInstalledRewardEquipmentCatalog(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("explicit installed GameData integration environment required")
}
d, err := LoadRewardEquipmentCatalog(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
if len(d.equipment) == 0 {
t.Fatal("empty equipment catalog")
}
for id := range d.equipment {
if _, _, _, err := d.RollOptions(id); err != nil {
t.Fatalf("equipment %d: %v", id, err)
}
}
}
+239
View File
@@ -0,0 +1,239 @@
package gamedata
import (
"crypto/rand"
"fmt"
"math"
"math/big"
"sort"
"sync"
)
// RewardGraph resolves random-box nodes; costume/equipment leaves retain
// their design IDs for the owning grant domain.
type RewardGraph struct {
mu sync.Mutex
boxes map[uint64]uint64
direct map[uint64]bool
groups map[uint64][]byte
sample func(uint64) (uint64, error)
}
func LoadRewardGraph(root, version string) (*RewardGraph, error) {
db, done, e := openStatDatabase(root, version)
if e != nil {
return nil, e
}
defer done()
g := &RewardGraph{boxes: map[uint64]uint64{}, direct: map[uint64]bool{}, groups: map[uint64][]byte{}}
g.sample = func(n uint64) (uint64, error) {
if n == 0 {
return 0, fmt.Errorf("gamedata: empty reward pool")
}
v, e := rand.Int(rand.Reader, new(big.Int).SetUint64(n))
if e != nil {
return 0, e
}
return v.Uint64(), nil
}
rows, e := db.Query("SELECT id,ProtoBuf FROM RandomBoxTable")
if e != nil {
return nil, e
}
for rows.Next() {
var id uint64
var raw []byte
if e = rows.Scan(&id, &raw); e != nil {
rows.Close()
return nil, e
}
gid, e := optionalScalar(raw, 9)
if e != nil {
rows.Close()
return nil, e
}
drop, err := optionalScalar(raw, 1)
if err != nil {
rows.Close()
return nil, err
}
g.direct[id] = drop == 1
g.boxes[id] = gid
}
e = rows.Err()
rows.Close()
if e != nil {
return nil, e
}
rows, e = db.Query("SELECT id,ProtoBuf FROM RewardGroupTable")
if e != nil {
return nil, e
}
for rows.Next() {
var id uint64
var raw []byte
if e = rows.Scan(&id, &raw); e != nil {
rows.Close()
return nil, e
}
g.groups[id] = append([]byte(nil), raw...)
}
e = rows.Err()
rows.Close()
return g, e
}
func (g *RewardGraph) SetSampler(f func(uint64) (uint64, error)) {
g.mu.Lock()
defer g.mu.Unlock()
g.sample = f
}
func (g *RewardGraph) Resolve(rewards []BattleReward) ([]BattleReward, error) {
g.mu.Lock()
defer g.mu.Unlock()
budget := uint64(100000)
totals := map[[2]uint64]uint64{}
seen := map[uint64]bool{}
var emit func(BattleReward) error
emit = func(r BattleReward) error {
if r.Count == 0 || r.Count > math.MaxInt32 || r.Type == 0 {
return fmt.Errorf("gamedata: invalid reward graph quantity/type")
}
if budget == 0 {
return fmt.Errorf("gamedata: reward graph operation limit")
}
budget--
if r.Type != 9 {
k := [2]uint64{r.Type, r.ID}
if totals[k] > math.MaxInt32-r.Count {
return fmt.Errorf("gamedata: reward graph overflow")
}
totals[k] += r.Count
return nil
}
if seen[r.ID] || len(seen) >= 32 {
return fmt.Errorf("gamedata: reward graph cycle")
}
gid, ok := g.boxes[r.ID]
if !ok {
return fmt.Errorf("gamedata: unknown random box %d", r.ID)
}
raw, ok := g.groups[gid]
if !ok {
return fmt.Errorf("gamedata: missing reward group %d", gid)
}
drop, e := optionalScalar(raw, 2)
if e != nil {
return e
}
count, e := optionalScalar(raw, 1)
if e != nil {
return e
}
children, e := eventGameRewards(raw, 6, 5, 4)
if e != nil || len(children) == 0 {
return fmt.Errorf("gamedata: invalid random box reward group %d", gid)
}
weights, e := packedInts(raw, 8)
if e != nil {
return e
}
if drop > 1 || len(weights) != len(children) || drop == 0 && count == 0 {
return fmt.Errorf("gamedata: invalid reward group selection mode")
}
seen[r.ID] = true
defer delete(seen, r.ID)
for n := uint64(0); n < r.Count; n++ {
if budget == 0 {
return fmt.Errorf("gamedata: reward graph operation limit")
}
budget--
if drop == 1 {
// Local server policy: direct components independently roll their GameData percentage.
for i, child := range children {
if weights[i] > 100 {
return fmt.Errorf("gamedata: direct reward percent exceeds 100")
}
x, e := g.sample(100)
if e != nil {
return e
}
if x >= 100 {
return fmt.Errorf("gamedata: sampler out of range")
}
if x >= weights[i] {
continue
}
if e = emit(child); e != nil {
return e
}
}
continue
}
sum := uint64(0)
for _, w := range weights {
if sum > math.MaxUint64-w {
return fmt.Errorf("gamedata: reward graph weight overflow")
}
sum += w
}
if sum == 0 || g.sample == nil {
return fmt.Errorf("gamedata: empty weighted reward pool")
}
for i := uint64(0); i < count; i++ {
if budget == 0 {
return fmt.Errorf("gamedata: reward graph operation limit")
}
x, e := g.sample(sum)
if e != nil {
return e
}
if x >= sum {
return fmt.Errorf("gamedata: random result outside reward pool")
}
for j, w := range weights {
if x < w {
if e = emit(children[j]); e != nil {
return e
}
break
}
x -= w
}
}
}
return nil
}
for _, r := range rewards {
if e := emit(r); e != nil {
return nil, e
}
}
var out []BattleReward
for k, v := range totals {
out = append(out, BattleReward{Type: k[0], ID: k[1], Count: v})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Type != out[j].Type {
return out[i].Type < out[j].Type
}
return out[i].ID < out[j].ID
})
return out, nil
}
// ResolveGranted preserves manually opened boxes; only RbdDirect boxes expand.
func (g *RewardGraph) ResolveGranted(rewards []BattleReward) ([]BattleReward, error) {
var out []BattleReward
for _, r := range rewards {
if r.Type == 9 && !g.direct[r.ID] {
out = append(out, r)
continue
}
expanded, e := g.Resolve([]BattleReward{r})
if e != nil {
return nil, e
}
out = append(out, expanded...)
}
return out, nil
}
@@ -0,0 +1,60 @@
package gamedata
import (
"bd2server/internal/server/wire"
"os"
"testing"
)
func group(drop, count uint64, rs []BattleReward, ws []uint64) []byte {
b := wire.AppendVarint(nil, 2, drop)
b = wire.AppendVarint(b, 1, count)
for _, r := range rs {
b = wire.AppendVarint(b, 6, r.Type)
b = wire.AppendVarint(b, 5, r.ID)
b = wire.AppendVarint(b, 4, r.Count)
}
for _, w := range ws {
b = wire.AppendVarint(b, 8, w)
}
return b
}
func TestRewardGraphWeightedNestedAndEquipmentLeaves(t *testing.T) {
g := &RewardGraph{boxes: map[uint64]uint64{1: 1, 2: 2}, groups: map[uint64][]byte{1: group(0, 1, []BattleReward{{9, 2, 1}, {8, 8, 2}}, []uint64{1, 3}), 2: group(1, 0, []BattleReward{{10, 10, 1}, {11, 11, 1}}, []uint64{100, 100})}, sample: func(uint64) (uint64, error) { return 0, nil }}
out, e := g.Resolve([]BattleReward{{9, 1, 2}})
if e != nil || len(out) != 2 || out[0].Type != 10 || out[0].Count != 2 || out[1].Type != 11 {
t.Fatalf("out=%v e=%v", out, e)
}
g.SetSampler(func(n uint64) (uint64, error) { return n - 1, nil })
out, e = g.Resolve([]BattleReward{{9, 1, 1}})
if e != nil || len(out) != 1 || out[0].Type != 8 || out[0].Count != 2 {
t.Fatalf("out=%v e=%v", out, e)
}
}
func TestRewardGraphCycleBadRandomAndOverflow(t *testing.T) {
g := &RewardGraph{boxes: map[uint64]uint64{1: 1}, groups: map[uint64][]byte{1: group(1, 0, []BattleReward{{9, 1, 1}}, nil)}}
if _, e := g.Resolve([]BattleReward{{9, 1, 1}}); e == nil {
t.Fatal("cycle accepted")
}
g.groups[1] = group(0, 1, []BattleReward{{8, 1, 1}}, []uint64{1})
g.sample = func(n uint64) (uint64, error) { return n, nil }
if _, e := g.Resolve([]BattleReward{{9, 1, 1}}); e == nil {
t.Fatal("bad random accepted")
}
if _, e := g.Resolve([]BattleReward{{8, 1, 2147483647}, {8, 1, 1}}); e == nil {
t.Fatal("overflow accepted")
}
}
func TestRewardGraphInstalledLoad(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not set")
}
g, e := LoadRewardGraph(root, "20260923193640")
if e != nil {
t.Fatal(e)
}
if len(g.boxes) == 0 || len(g.groups) == 0 {
t.Fatal("empty graph")
}
}
@@ -0,0 +1,28 @@
package gamedata
func LoadPrestigeSkins(root, version string) (map[uint64]uint64, error) {
db, done, e := openStatDatabase(root, version)
if e != nil {
return nil, e
}
defer done()
rows, e := db.Query("SELECT id,ProtoBuf FROM PrestigeSkinTable")
if e != nil {
return nil, e
}
defer rows.Close()
out := map[uint64]uint64{}
for rows.Next() {
var id uint64
var raw []byte
if e = rows.Scan(&id, &raw); e != nil {
return nil, e
}
costume, e := optionalScalar(raw, 1)
if e != nil {
return nil, e
}
out[id] = costume
}
return out, rows.Err()
}
+1 -21
View File
@@ -5,8 +5,6 @@ import (
"encoding/binary"
"fmt"
"math"
"os"
"path/filepath"
"bd2server/internal/server/wire"
_ "modernc.org/sqlite"
@@ -326,23 +324,5 @@ func fixed32Floats(proto []byte, number int) ([]float64, error) {
}
func openStatDatabase(root, version string) (*sql.DB, func(), error) {
plain, err := ReadQuestDatabase(root, version)
if err != nil {
return nil, nil, err
}
dir, err := os.MkdirTemp("", "bd2-stats-")
if err != nil {
return nil, nil, err
}
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
os.RemoveAll(dir)
return nil, nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
os.RemoveAll(dir)
return nil, nil, err
}
return db, func() { _ = db.Close(); _ = os.RemoveAll(dir) }, nil
return OpenDatabase(root, version, "common")
}
+2 -17
View File
@@ -4,8 +4,6 @@ import (
"database/sql"
"fmt"
"math"
"os"
"path/filepath"
)
// WaypointPack uses the per-pack FieldWaypointTable and common PackTable.
@@ -43,24 +41,11 @@ func LoadWaypointPack(root, version string, packID uint64) (WaypointPack, error)
*target = values[0]
}
}
plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID))
db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", packID))
if err != nil {
return WaypointPack{}, err
}
dir, err := os.MkdirTemp("", "bd2-waypoint-")
if err != nil {
return WaypointPack{}, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "pack.db")
if err = os.WriteFile(path, plain, 0600); err != nil {
return WaypointPack{}, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return WaypointPack{}, err
}
defer db.Close()
defer release()
design.Points, err = loadWaypointPoints(db)
return design, err
}
@@ -0,0 +1,69 @@
package gamedata
import (
"os"
"testing"
)
// Opt-in performance verification, not a source for game design assertions.
func BenchmarkInstalledWaypointPackWarm(b *testing.B) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
b.Skip("BD2_REAL_GAMEDATA not configured")
}
if err := CloseDatabaseCache(); err != nil {
b.Fatal(err)
}
b.Cleanup(func() {
if err := CloseDatabaseCache(); err != nil {
b.Error(err)
}
})
if _, err := LoadWaypointPack(root, "20260923193640", 21); err != nil {
b.Fatal(err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := LoadWaypointPack(root, "20260923193640", 21); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkInstalledWaypointPackCold(b *testing.B) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
b.Skip("BD2_REAL_GAMEDATA not configured")
}
b.Cleanup(func() { _ = CloseDatabaseCache() })
for i := 0; i < b.N; i++ {
b.StopTimer()
if err := CloseDatabaseCache(); err != nil {
b.Fatal(err)
}
b.StartTimer()
if _, err := LoadWaypointPack(root, "20260923193640", 21); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkInstalledPackDetailWarm(b *testing.B) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
b.Skip("BD2_REAL_GAMEDATA not configured")
}
if err := CloseDatabaseCache(); err != nil {
b.Fatal(err)
}
b.Cleanup(func() { _ = CloseDatabaseCache() })
if _, err := LoadPackDetailDesign(root, "20260923193640", 21); err != nil {
b.Fatal(err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := LoadPackDetailDesign(root, "20260923193640", 21); err != nil {
b.Fatal(err)
}
}
}