Files
bd2/go/internal/server/gamedata/random_box.go
T

154 lines
4.7 KiB
Go

package gamedata
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
// RandomBoxDesign contains only boxes whose GameData reward graph is wholly
// deterministic. The client asks the server to open every RandomBox, but an
// offline server must not invent an outcome for weighted boxes. Deterministic
// material boxes (including the engraving-scroll and essence boxes) are safe
// to resolve directly from the authoritative RewardGroupTable.
type RandomBoxDesign struct {
rewards map[uint64][]BattleReward
}
// LoadRandomBoxDesign reads RandomBoxTable -> RewardGroupTable from the
// installed common GameData. A box is deliberately omitted if its group is
// 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)
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()
groups := map[uint64][]BattleReward{}
rows, err := db.Query("SELECT id, ProtoBuf FROM RewardGroupTable")
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
}
ids, err := packedInts(raw, 5)
if err != nil {
rows.Close()
return nil, fmt.Errorf("gamedata: random box reward group %d item IDs: %w", id, err)
}
types, err := packedInts(raw, 6)
if err != nil {
rows.Close()
return nil, fmt.Errorf("gamedata: random box reward group %d item types: %w", id, err)
}
counts, err := packedInts(raw, 4)
if err != nil {
rows.Close()
return nil, fmt.Errorf("gamedata: random box reward group %d item counts: %w", id, err)
}
ratios, err := packedInts(raw, 8)
if err != nil {
rows.Close()
return nil, fmt.Errorf("gamedata: random box reward group %d ratios: %w", id, err)
}
// A single reward is deterministic. Ratios are presentation/probability
// metadata in that case; a multi-entry group is intentionally not used.
if len(ids) != 1 || len(types) != 1 || len(counts) != 1 || ids[0] == 0 || !itemDBInfoRewardType(types[0]) || counts[0] == 0 || (len(ratios) != 0 && len(ratios) != 1) {
continue
}
groups[id] = []BattleReward{{Type: types[0], ID: ids[0], Count: counts[0]}}
}
if err := rows.Close(); err != nil {
return nil, err
}
design := &RandomBoxDesign{rewards: map[uint64][]BattleReward{}}
rows, err = db.Query("SELECT id, ProtoBuf FROM RandomBoxTable")
if err != nil {
return nil, err
}
for rows.Next() {
var rowID uint64
var raw []byte
if err := rows.Scan(&rowID, &raw); err != nil {
rows.Close()
return nil, err
}
ids, err := packedInts(raw, 4)
if err != nil || len(ids) != 1 || ids[0] != rowID {
rows.Close()
return nil, fmt.Errorf("gamedata: malformed random box %d", rowID)
}
groupIDs, err := packedInts(raw, 9)
if err != nil || len(groupIDs) != 1 || groupIDs[0] == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: random box %d reward group: %w", rowID, err)
}
if rewards, ok := groups[groupIDs[0]]; ok {
design.rewards[rowID] = append([]BattleReward(nil), rewards...)
}
}
if err := rows.Close(); err != nil {
return nil, err
}
return design, nil
}
// itemDBInfoRewardType is the ItemDBInfo part of DataManager.GetItemInfo for
// the static tables this server persists in Inventory. Equipment, characters,
// costumes and currency each have a different domain store/response field, so
// a RandomBox resolving to one of them is intentionally not opened here.
func itemDBInfoRewardType(itemType uint64) bool {
switch itemType {
case 5, 7, 8, 9, 13, 14, 17, 27, 29:
return true
default:
return false
}
}
// Open returns the exact aggregate for count opens. It never samples a
// weighted group, which avoids silently fabricating a random result.
func (d *RandomBoxDesign) Open(boxID, count uint64) ([]BattleReward, error) {
if d == nil || count == 0 {
return nil, errors.New("gamedata: invalid random box open")
}
rewards, ok := d.rewards[boxID]
if !ok {
return nil, fmt.Errorf("gamedata: random box %d is not deterministic ItemDBInfo loot", boxID)
}
result := make([]BattleReward, len(rewards))
for i, reward := range rewards {
if reward.Count > ^uint64(0)/count {
return nil, errors.New("gamedata: random box reward count overflow")
}
result[i] = reward
result[i].Count *= count
}
return result, nil
}