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:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user