refactor(all): reorganize packages, establish player ownership and isolate accounts
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
// Package dbcrypt implements the page cipher used by versioned GameData
|
||||
// SQLite archives. Client resource patching owns a separate copy so the pure
|
||||
// server dependency graph never imports internal/client.
|
||||
package dbcrypt
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const PageSize = 4096
|
||||
|
||||
var Header = []byte("SQLite format 3\x00")
|
||||
|
||||
func DecryptPages(in []byte) ([]byte, error) { return cryptPages(in, false) }
|
||||
func EncryptPages(in []byte) ([]byte, error) { return cryptPages(in, true) }
|
||||
|
||||
func cryptPages(in []byte, encrypt bool) ([]byte, error) {
|
||||
if len(in) == 0 || len(in)%PageSize != 0 {
|
||||
return nil, fmt.Errorf("dbcrypt: database length %d is not a non-zero multiple of %d", len(in), PageSize)
|
||||
}
|
||||
block, err := aes.NewCipher(deriveKey())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]byte, len(in))
|
||||
for start := 0; start < len(in); start += PageSize {
|
||||
mode := cipher.NewCBCEncrypter(block, Header)
|
||||
if !encrypt {
|
||||
mode = cipher.NewCBCDecrypter(block, Header)
|
||||
}
|
||||
mode.CryptBlocks(out[start:start+PageSize], in[start:start+PageSize])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func deriveKey() []byte {
|
||||
password := []byte(fmt.Sprintf("%X", sha1.Sum([]byte("spdhdnlwmrpavmtm"))))
|
||||
return pbkdf2SHA1(password, Header, 2010, 32)
|
||||
}
|
||||
|
||||
func pbkdf2SHA1(password, salt []byte, iterations, length int) []byte {
|
||||
var result []byte
|
||||
for block := uint32(1); len(result) < length; block++ {
|
||||
message := append(append([]byte{}, salt...), byte(block>>24), byte(block>>16), byte(block>>8), byte(block))
|
||||
u := hmacSHA1(password, message)
|
||||
t := append([]byte{}, u...)
|
||||
for i := 1; i < iterations; i++ {
|
||||
u = hmacSHA1(password, u)
|
||||
for j := range t {
|
||||
t[j] ^= u[j]
|
||||
}
|
||||
}
|
||||
result = append(result, t...)
|
||||
}
|
||||
return result[:length]
|
||||
}
|
||||
|
||||
func hmacSHA1(key, message []byte) []byte {
|
||||
h := hmac.New(sha1.New, key)
|
||||
_, _ = h.Write(message)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Package gameconfig loads server-authoritative gameplay rules.
|
||||
package gameconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"bd2server/internal/server/platform/configfile"
|
||||
)
|
||||
|
||||
const FileName = "game.json"
|
||||
|
||||
type Config struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Gacha GachaConfig `json:"gacha"`
|
||||
Story StoryConfig `json:"story"`
|
||||
Purchases PurchasesConfig `json:"purchases"`
|
||||
}
|
||||
|
||||
type PurchasesConfig struct {
|
||||
DiamondRecharge DiamondRechargeConfig `json:"diamond_recharge"`
|
||||
}
|
||||
|
||||
// Only paid-diamond recharge has a configurable price. All other cash goods
|
||||
// use their GameData-derived paid-diamond price.
|
||||
type DiamondRechargeConfig struct {
|
||||
Currency string `json:"currency"`
|
||||
GoldPerPaidDiamond int64 `json:"gold_per_paid_diamond"`
|
||||
DiamondsPerPaidDiamond int64 `json:"diamonds_per_paid_diamond"`
|
||||
}
|
||||
|
||||
// StartPackID is fixed for the server by its first successful initialization.
|
||||
// TODO: Revisit this restriction if the official story rework merges the old
|
||||
// story and master packs into one progression. Its release date is unconfirmed;
|
||||
// it may extend into 2027, so do not infer or implement a merged chain now.
|
||||
type StoryConfig struct {
|
||||
StartPackID int `json:"start_pack_id"`
|
||||
}
|
||||
|
||||
type GachaConfig struct {
|
||||
IncludeCollaborationURWeapons bool `json:"include_collaboration_ur_weapons"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
return Config{SchemaVersion: 1, Story: StoryConfig{StartPackID: 21}, Purchases: PurchasesConfig{DiamondRecharge: DiamondRechargeConfig{Currency: "free", GoldPerPaidDiamond: 1000, DiamondsPerPaidDiamond: 1}}}
|
||||
}
|
||||
|
||||
// Load generates defaults when missing and strictly validates existing files.
|
||||
func Load(path string) (Config, error) {
|
||||
if err := configfile.Ensure(path, Default()); err != nil {
|
||||
return Config{}, fmt.Errorf("game configuration: initialize %s: %w", path, err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Clean(path))
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("game configuration: read %s: %w", path, err)
|
||||
}
|
||||
cfg := Default()
|
||||
// The format version is required even when gameplay sections are omitted.
|
||||
cfg.SchemaVersion = 0
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("game configuration: decode %s: %w", path, err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return Config{}, fmt.Errorf("game configuration: trailing data in %s", path)
|
||||
}
|
||||
// encoding/json accepts null for scalar and struct fields. These rules have
|
||||
// explicit defaults for omissions, while null is a configuration error.
|
||||
var document any
|
||||
if err := json.Unmarshal(data, &document); err != nil {
|
||||
return Config{}, fmt.Errorf("game configuration: decode %s: %w", path, err)
|
||||
}
|
||||
if containsNull(document) {
|
||||
return Config{}, fmt.Errorf("game configuration: null is not allowed in %s", path)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, fmt.Errorf("game configuration: %s: %w", path, err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func containsNull(value any) bool {
|
||||
switch value := value.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case map[string]any:
|
||||
for _, child := range value {
|
||||
if containsNull(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
return slices.ContainsFunc(value, containsNull)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.SchemaVersion != 1 {
|
||||
return fmt.Errorf("unsupported schema_version %d (expected 1)", c.SchemaVersion)
|
||||
}
|
||||
if c.Story.StartPackID != 1 && c.Story.StartPackID != 21 {
|
||||
return fmt.Errorf("story.start_pack_id must be 1 or 21, got %d", c.Story.StartPackID)
|
||||
}
|
||||
r := c.Purchases.DiamondRecharge
|
||||
switch r.Currency {
|
||||
case "free", "gold", "diamonds", "ban", "":
|
||||
default:
|
||||
return fmt.Errorf("purchases.diamond_recharge.currency must be free, gold, diamonds, ban, or an empty string")
|
||||
}
|
||||
if r.GoldPerPaidDiamond < 1 || r.GoldPerPaidDiamond > 1_000_000_000 || r.DiamondsPerPaidDiamond < 1 || r.DiamondsPerPaidDiamond > 1_000_000_000 {
|
||||
return fmt.Errorf("purchases.diamond_recharge conversion rates must be integers between 1 and 1000000000")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func BesideExecutable() (string, error) {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("game configuration: resolve server executable: %w", err)
|
||||
}
|
||||
return filepath.Join(filepath.Dir(executable), FileName), nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// AchievementCounterDesign indexes the content groups to which each counter belongs.
|
||||
// AchievementTable has several tiers with the same group ID; id is not unique.
|
||||
type AchievementCondition struct{ Type, SubType, ParentGroup uint64 }
|
||||
type AchievementCounterDesign struct {
|
||||
Groups map[int][]int
|
||||
Conditions map[int]AchievementCondition
|
||||
}
|
||||
|
||||
func LoadAchievementCounterDesign(root, version string) (*AchievementCounterDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
return loadAchievementCounterDesign(db)
|
||||
}
|
||||
|
||||
func loadAchievementCounterDesign(db *sql.DB) (*AchievementCounterDesign, error) {
|
||||
rows, err := db.Query("SELECT ProtoBuf FROM AchievementTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
sets := map[int]map[int]bool{}
|
||||
conditions := map[int]AchievementCondition{}
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields := map[int]int{}
|
||||
for _, field := range []int{1, 2, 4, 9, 13} {
|
||||
values, err := packedInts(raw, field)
|
||||
if err != nil || len(values) > 1 || (len(values) == 1 && values[0] > math.MaxInt32) {
|
||||
return nil, fmt.Errorf("gamedata: invalid achievement field%d", field)
|
||||
}
|
||||
if len(values) > 0 {
|
||||
fields[field] = int(values[0])
|
||||
}
|
||||
}
|
||||
// Client condition lookup SQL filters parentGroupId=0.
|
||||
// The wire update carries no content group.
|
||||
|
||||
if fields[13] != 0 {
|
||||
continue
|
||||
}
|
||||
group, content := fields[9], fields[4]
|
||||
if group <= 0 || content < 0 || content > 1 {
|
||||
return nil, fmt.Errorf("gamedata: invalid achievement group %d/%d", group, content)
|
||||
}
|
||||
condition := AchievementCondition{Type: uint64(fields[2]), SubType: uint64(fields[1]), ParentGroup: uint64(fields[13])}
|
||||
if previous, ok := conditions[group]; ok && previous != condition {
|
||||
return nil, fmt.Errorf("gamedata: achievement group condition mismatch")
|
||||
}
|
||||
conditions[group] = condition
|
||||
if sets[group] == nil {
|
||||
sets[group] = map[int]bool{}
|
||||
}
|
||||
sets[group][content] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
design := &AchievementCounterDesign{Groups: map[int][]int{}, Conditions: conditions}
|
||||
for group, contents := range sets {
|
||||
for content := range contents {
|
||||
design.Groups[group] = append(design.Groups[group], content)
|
||||
}
|
||||
sort.Ints(design.Groups[group])
|
||||
}
|
||||
if len(design.Groups) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: empty achievement design")
|
||||
}
|
||||
return design, nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package gamedata
|
||||
|
||||
import "fmt"
|
||||
|
||||
// GameplayAchievementGrades resolves subtype rarity from the current tables.
|
||||
// Instances contain design IDs, never inferred numeric ID ranges.
|
||||
type GameplayAchievementGrades struct{ Characters, Equipment map[uint64]uint64 }
|
||||
|
||||
func LoadGameplayAchievementGrades(root, version string) (GameplayAchievementGrades, error) {
|
||||
d := GameplayAchievementGrades{Characters: map[uint64]uint64{}, Equipment: map[uint64]uint64{}}
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
defer closeDB()
|
||||
for _, spec := range []struct {
|
||||
table string
|
||||
field int
|
||||
target map[uint64]uint64
|
||||
}{{"CharTable", 9, d.Characters}, {"EquipmentTable", 3, d.Equipment}} {
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM " + spec.table)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
_ = rows.Close()
|
||||
return d, err
|
||||
}
|
||||
grade, err := packedInts(raw, spec.field)
|
||||
if err != nil || len(grade) != 1 || grade[0] == 0 {
|
||||
_ = rows.Close()
|
||||
return d, fmt.Errorf("gamedata: invalid achievement grade %s/%d", spec.table, id)
|
||||
}
|
||||
spec.target[id] = grade[0]
|
||||
}
|
||||
err = rows.Err()
|
||||
_ = rows.Close()
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type AchievementLevel struct {
|
||||
ID, NeedEXP uint64
|
||||
Rewards []Reward
|
||||
}
|
||||
type AchievementLevelDesign struct{ Levels []AchievementLevel }
|
||||
|
||||
func LoadAchievementLevelDesign(root, version string) (*AchievementLevelDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
rows, err := db.Query("SELECT ProtoBuf FROM AchievementLevelTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
d := &AchievementLevelDesign{}
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := optionalScalar(raw, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
exp, err := optionalScalar(raw, 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rewards, err := parallelRewards(raw, 5, 4, 3)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
types, err := packedInts(raw, 5)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rewards) != len(types) {
|
||||
return nil, fmt.Errorf("gamedata: achievement level reward arrays mismatch")
|
||||
}
|
||||
d.Levels = append(d.Levels, AchievementLevel{id, exp, rewards})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(d.Levels, func(i, j int) bool { return d.Levels[i].ID < d.Levels[j].ID })
|
||||
if err := d.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
func (d *AchievementLevelDesign) Validate() error {
|
||||
if d == nil || len(d.Levels) == 0 {
|
||||
return fmt.Errorf("gamedata: empty achievement level design")
|
||||
}
|
||||
var previous, total uint64
|
||||
for _, l := range d.Levels {
|
||||
if l.ID <= previous || l.ID > math.MaxInt32 || l.NeedEXP == 0 || l.NeedEXP > math.MaxInt32 || total > math.MaxUint64-l.NeedEXP {
|
||||
return fmt.Errorf("gamedata: invalid achievement level design")
|
||||
}
|
||||
previous = l.ID
|
||||
total += l.NeedEXP
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Level follows the client: the first cumulative requirement strictly above EXP.
|
||||
func (d *AchievementLevelDesign) Level(exp uint64) uint64 {
|
||||
var total uint64
|
||||
for _, l := range d.Levels {
|
||||
total += l.NeedEXP
|
||||
if total > exp {
|
||||
return l.ID
|
||||
}
|
||||
}
|
||||
return d.Levels[len(d.Levels)-1].ID
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package gamedata
|
||||
|
||||
import "math"
|
||||
|
||||
// Stat is the small, protocol-independent subset of character statistics
|
||||
// needed by the local server. The client uses the same two kinds of values
|
||||
// for every stat: a flat contribution and a percentage contribution.
|
||||
type Stat int
|
||||
|
||||
const (
|
||||
StatHealth Stat = iota + 1
|
||||
StatAttack
|
||||
StatMagic
|
||||
StatDefencePercent
|
||||
StatMagicResistancePercent
|
||||
StatCriticalChance
|
||||
StatCriticalDamage
|
||||
StatElementDamage
|
||||
StatElementResistance
|
||||
)
|
||||
|
||||
// StatContribution is one already-resolved contribution from an equipment
|
||||
// option, costume, collection, awakening, or another account system. Percent
|
||||
// is expressed as a fraction (0.08 means 8%), matching the client formula.
|
||||
type StatContribution struct {
|
||||
Stat Stat
|
||||
Flat float64
|
||||
Percent float64
|
||||
// Option preserves the exact Define_CharStatOption for systems such as
|
||||
// awakening whose element-specific distinctions exceed BaseStats.
|
||||
Option uint64
|
||||
}
|
||||
|
||||
// BaseStats contains the design value after the character level curve has
|
||||
// been applied. It is deliberately separate from CharacterStats so callers
|
||||
// cannot accidentally persist a derived value as design data.
|
||||
type BaseStats struct {
|
||||
Health float64
|
||||
Attack float64
|
||||
Magic float64
|
||||
}
|
||||
|
||||
// AggregateStats applies the final client-side aggregation order:
|
||||
//
|
||||
// final = base + round(((base + flat) * (1 + percent)) - base)
|
||||
//
|
||||
// The game truncates the design value before applying account contributions;
|
||||
// the final bonus is truncated toward zero as well. Keeping this operation
|
||||
// in one place prevents revival, CharInfo, and battle code from developing
|
||||
// subtly different HP calculations.
|
||||
func AggregateStats(base BaseStats, contributions []StatContribution) BaseStats {
|
||||
flat := map[Stat]float64{}
|
||||
percent := map[Stat]float64{}
|
||||
for _, contribution := range contributions {
|
||||
if contribution.Stat == 0 {
|
||||
continue
|
||||
}
|
||||
flat[contribution.Stat] += contribution.Flat
|
||||
percent[contribution.Stat] += contribution.Percent
|
||||
}
|
||||
return BaseStats{
|
||||
Health: aggregateOne(base.Health, flat[StatHealth], percent[StatHealth]),
|
||||
Attack: aggregateOne(base.Attack, flat[StatAttack], percent[StatAttack]),
|
||||
Magic: aggregateOne(base.Magic, flat[StatMagic], percent[StatMagic]),
|
||||
}
|
||||
}
|
||||
|
||||
func aggregateOne(base, flat, percent float64) float64 {
|
||||
base = math.Trunc(base)
|
||||
bonus := ((base + flat) * (1 + percent)) - base
|
||||
return base + math.Trunc(bonus)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// Avatar sets are design bundles: ownership is checked on each member by the
|
||||
// client, rather than on a synthetic AvatarSet ItemDBInfo.
|
||||
type AvatarRewardDesign struct {
|
||||
Sets map[uint64][]BattleReward
|
||||
Items map[uint64]map[uint64]bool
|
||||
}
|
||||
|
||||
func LoadAvatarRewardDesign(root, version string) (*AvatarRewardDesign, error) {
|
||||
db, done, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
return loadAvatarRewardDesign(db)
|
||||
}
|
||||
func loadAvatarRewardDesign(db *sql.DB) (*AvatarRewardDesign, error) {
|
||||
d := &AvatarRewardDesign{Sets: map[uint64][]BattleReward{}, Items: map[uint64]map[uint64]bool{49: {}, 50: {}, 61: {}}}
|
||||
for _, table := range []struct {
|
||||
name string
|
||||
typ uint64
|
||||
field int
|
||||
}{{"AvatarItemTable", 49, 3}, {"AvatarMotionTable", 50, 5}, {"AvatarCharTable", 61, 6}} {
|
||||
if err := readCashMetadata(db, table.name, func(raw []byte) error {
|
||||
id, err := optionalScalar(raw, table.field)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if id == 0 {
|
||||
return fmt.Errorf("gamedata: invalid avatar member")
|
||||
}
|
||||
d.Items[table.typ][id] = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := readCashMetadata(db, "AvatarSetTable", func(raw []byte) error {
|
||||
id, err := optionalScalar(raw, 6)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
members, err := eventGameRewards(raw, 5, 4, 3)
|
||||
if err != nil || id == 0 || len(members) == 0 {
|
||||
return fmt.Errorf("gamedata: invalid avatar set")
|
||||
}
|
||||
for _, member := range members {
|
||||
if !d.Items[member.Type][member.ID] || member.Count == 0 || member.Count > math.MaxInt32 {
|
||||
return fmt.Errorf("gamedata: avatar set %d has invalid member %d:%d", id, member.Type, member.ID)
|
||||
}
|
||||
}
|
||||
d.Sets[id] = members
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
func (d *AvatarRewardDesign) Expand(rewards []BattleReward) ([]BattleReward, error) {
|
||||
var out []BattleReward
|
||||
for _, reward := range rewards {
|
||||
if reward.Type != 62 {
|
||||
out = append(out, reward)
|
||||
continue
|
||||
}
|
||||
if d == nil || reward.Count == 0 || reward.Count > math.MaxInt32 {
|
||||
return nil, fmt.Errorf("gamedata: avatar reward design or quantity unavailable")
|
||||
}
|
||||
members, ok := d.Sets[reward.ID]
|
||||
if !ok || len(members) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: unknown avatar set %d", reward.ID)
|
||||
}
|
||||
for _, member := range members {
|
||||
if !d.Items[member.Type][member.ID] || member.Count == 0 || member.Count > math.MaxInt32/reward.Count {
|
||||
return nil, fmt.Errorf("gamedata: invalid avatar set member")
|
||||
}
|
||||
member.Count *= reward.Count
|
||||
out = append(out, member)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
|
||||
"bd2server/internal/server/protocol/wire"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// BattleReward is a static victory reward from BattleDeckTable in a pack DB.
|
||||
// It is a design definition, not a player's owned item or server-generated ID.
|
||||
type BattleReward struct {
|
||||
Type uint64
|
||||
ID uint64
|
||||
Count uint64
|
||||
}
|
||||
|
||||
// BattleDeckRewards uses the enemy battle deck actually chosen by the client
|
||||
// (BattleEnter field 4); a monster may have several different battle decks.
|
||||
func BattleDeckRewards(root, version string, packID int, deckID uint64) ([]BattleReward, error) {
|
||||
if packID <= 0 || deckID == 0 || deckID > uint64(^uint(0)>>1) {
|
||||
return nil, fmt.Errorf("gamedata: invalid battle pack/deck %d/%d", packID, deckID)
|
||||
}
|
||||
return readBattleRewards(root, version, packID, int(deckID), 0)
|
||||
}
|
||||
|
||||
// BattleRewards is a legacy convenience for a monster with exactly one deck.
|
||||
// Call BattleDeckRewards for a real battle, where the chosen deck is known.
|
||||
func BattleRewards(root, version string, packID, monsterID int) ([]BattleReward, error) {
|
||||
if packID <= 0 || monsterID <= 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid battle pack/monster %d/%d", packID, monsterID)
|
||||
}
|
||||
return readBattleRewards(root, version, packID, 0, monsterID)
|
||||
}
|
||||
|
||||
func readBattleRewards(root, version string, packID, deckID, monsterID int) ([]BattleReward, error) {
|
||||
db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", packID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
if monsterID != 0 {
|
||||
var monster []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM FieldMonsterTable WHERE id=?", monsterID).Scan(&monster); err != nil {
|
||||
return nil, fmt.Errorf("gamedata: monster %d: %w", monsterID, err)
|
||||
}
|
||||
deckIDs, err := packedInts(monster, 2)
|
||||
if err != nil || len(deckIDs) != 1 || deckIDs[0] == 0 || deckIDs[0] > uint64(^uint(0)>>1) {
|
||||
return nil, fmt.Errorf("gamedata: monster %d requires a selected battle deck (found %v): %w", monsterID, deckIDs, err)
|
||||
}
|
||||
deckID = int(deckIDs[0])
|
||||
}
|
||||
var design []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM BattleDeckTable WHERE id=?", deckID).Scan(&design); err != nil {
|
||||
return nil, fmt.Errorf("gamedata: battle deck %d: %w", deckID, err)
|
||||
}
|
||||
types, err := packedInts(design, 36)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids, err := packedInts(design, 34)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts, err := packedInts(design, 33)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(types) != len(ids) || len(ids) != len(counts) {
|
||||
return nil, fmt.Errorf("gamedata: battle deck %d reward arrays differ in length", deckID)
|
||||
}
|
||||
rewards := make([]BattleReward, len(types))
|
||||
for i := range rewards {
|
||||
validID := ids[i] != 0
|
||||
switch types[i] {
|
||||
case 2, 3, 4, 12, 20, 22, 68:
|
||||
validID = ids[i] == 0
|
||||
}
|
||||
if types[i] == 0 || !validID || counts[i] == 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid battle deck %d reward", deckID)
|
||||
}
|
||||
rewards[i] = BattleReward{Type: types[i], ID: ids[i], Count: counts[i]}
|
||||
}
|
||||
return rewards, nil
|
||||
}
|
||||
|
||||
func packedInts(proto []byte, number int) ([]uint64, error) {
|
||||
var out []uint64
|
||||
err := wire.Walk(proto, func(f wire.Field) error {
|
||||
if f.Number != number {
|
||||
return nil
|
||||
}
|
||||
if f.Type != 0 && f.Type != 2 {
|
||||
return fmt.Errorf("gamedata: field %d has type %d", number, f.Type)
|
||||
}
|
||||
for data := f.Value; len(data) > 0; {
|
||||
value, n := binary.Uvarint(data)
|
||||
if n <= 0 {
|
||||
return fmt.Errorf("gamedata: invalid packed field %d", number)
|
||||
}
|
||||
out = append(out, value)
|
||||
data = data[n:]
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type QuestBattleDeck struct {
|
||||
DeckID uint64
|
||||
QuestIDs []uint64
|
||||
}
|
||||
|
||||
// ResolveQuestBattleDeck applies story difficulty only to monsters bound to
|
||||
// main quests. Side quests use their authored deck regardless of story level.
|
||||
func ResolveQuestBattleDeck(root, version string, packID int, monsterID, deckID, difficulty uint64) (QuestBattleDeck, error) {
|
||||
if monsterID == 0 || monsterID > 2147483647 || deckID == 0 || deckID > 2147483647 || difficulty > 2 {
|
||||
return QuestBattleDeck{}, fmt.Errorf("gamedata: invalid quest monster/deck/difficulty %d/%d/%d", monsterID, deckID, difficulty)
|
||||
}
|
||||
db, cleanup, err := openPackDatabase(root, version, packID)
|
||||
if err != nil {
|
||||
return QuestBattleDeck{}, err
|
||||
}
|
||||
defer cleanup()
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM FieldMonsterTable WHERE id=?", monsterID).Scan(&raw); err != nil {
|
||||
return QuestBattleDeck{}, fmt.Errorf("gamedata: quest monster %d: %w", monsterID, err)
|
||||
}
|
||||
quests, err := packedInts(raw, 22)
|
||||
if err != nil {
|
||||
return QuestBattleDeck{}, err
|
||||
}
|
||||
if len(quests) == 0 {
|
||||
return QuestBattleDeck{}, fmt.Errorf("gamedata: quest monster %d has no quest range", monsterID)
|
||||
}
|
||||
decks, err := packedInts(raw, 2)
|
||||
if err != nil {
|
||||
return QuestBattleDeck{}, err
|
||||
}
|
||||
group, err := phaseScalar(raw, 21)
|
||||
if err != nil {
|
||||
return QuestBattleDeck{}, err
|
||||
}
|
||||
common, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return QuestBattleDeck{}, err
|
||||
}
|
||||
defer release()
|
||||
var kind uint64
|
||||
for i, quest := range quests {
|
||||
if quest == 0 || quest > 2147483647 {
|
||||
return QuestBattleDeck{}, fmt.Errorf("gamedata: quest monster %d has invalid quest %d", monsterID, quest)
|
||||
}
|
||||
if err := common.QueryRow(fmt.Sprintf("SELECT ProtoBuf FROM QuestTable%d WHERE id=?", packID), quest).Scan(&raw); err != nil {
|
||||
return QuestBattleDeck{}, fmt.Errorf("gamedata: monster %d quest %d: %w", monsterID, quest, err)
|
||||
}
|
||||
questKind, err := phaseScalar(raw, 63)
|
||||
if err != nil {
|
||||
return QuestBattleDeck{}, err
|
||||
}
|
||||
if questKind > 1 || (i > 0 && questKind != kind) {
|
||||
return QuestBattleDeck{}, fmt.Errorf("gamedata: quest monster %d has unsupported or mixed quest types", monsterID)
|
||||
}
|
||||
kind = questKind
|
||||
}
|
||||
if kind == 1 {
|
||||
difficulty = 0
|
||||
}
|
||||
selected, err := battleDeckForDifficultyFromDB(db, deckID, difficulty)
|
||||
if err != nil {
|
||||
return QuestBattleDeck{}, err
|
||||
}
|
||||
if group == 0 {
|
||||
allowed := slices.Contains(decks, deckID)
|
||||
if kind == 0 && selected > difficulty*100000 {
|
||||
allowed = allowed || slices.Contains(decks, selected-difficulty*100000)
|
||||
}
|
||||
if !allowed {
|
||||
return QuestBattleDeck{}, fmt.Errorf("gamedata: quest monster %d does not contain deck %d", monsterID, deckID)
|
||||
}
|
||||
} else if _, err := battleDeckPhasesFromDB(db, monsterID, selected); err != nil {
|
||||
return QuestBattleDeck{}, err
|
||||
}
|
||||
return QuestBattleDeck{DeckID: selected, QuestIDs: quests}, nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return OpenDatabase(root, version, fmt.Sprintf("pack%d", packID))
|
||||
}
|
||||
|
||||
func battleDeckForDifficultyFromDB(db *sql.DB, deckID, difficulty uint64) (uint64, error) {
|
||||
if deckID == 0 || deckID > 2147483647 || difficulty > 2 {
|
||||
return 0, fmt.Errorf("gamedata: unsupported battle deck/difficulty %d/%d", deckID, difficulty)
|
||||
}
|
||||
var data []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM BattleDeckTable WHERE id=?", deckID).Scan(&data); err != nil {
|
||||
return 0, fmt.Errorf("gamedata: battle deck %d: %w", deckID, err)
|
||||
}
|
||||
actual, err := phaseScalar(data, 24)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if actual == difficulty {
|
||||
return deckID, nil
|
||||
}
|
||||
// Only an ordinary deck can be promoted. Changing a previously selected
|
||||
// difficulty deck would conceal stale client quest state.
|
||||
if actual != 0 || difficulty == 0 {
|
||||
return 0, fmt.Errorf("gamedata: deck %d difficulty %d differs from selected %d", deckID, actual, difficulty)
|
||||
}
|
||||
selected := deckID + difficulty*100000
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM BattleDeckTable WHERE id=?", selected).Scan(&data); err != nil {
|
||||
return 0, fmt.Errorf("gamedata: difficulty deck %d: %w", selected, err)
|
||||
}
|
||||
actual, err = phaseScalar(data, 24)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if actual != difficulty {
|
||||
return 0, fmt.Errorf("gamedata: difficulty deck %d has difficulty %d, want %d", selected, actual, difficulty)
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"bd2server/internal/server/protocol/wire"
|
||||
)
|
||||
|
||||
// BattlePhase identifies a row in the pack's PhaseBattleTable. ID is the
|
||||
// design row ID sent as current_phase_id, not a zero-based list offset.
|
||||
type BattlePhase struct{ GroupID, ID, DeckID uint64 }
|
||||
|
||||
// BattleDeckPhases resolves a monster's phase group and verifies that the
|
||||
// selected deck belongs to it. Ordinary monsters have no phase group.
|
||||
func BattleDeckPhases(root, version string, packID int, monsterID, deckID uint64) ([]BattlePhase, error) {
|
||||
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)
|
||||
}
|
||||
db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", packID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
return battleDeckPhasesFromDB(db, monsterID, deckID)
|
||||
}
|
||||
|
||||
func battleDeckPhasesFromDB(db *sql.DB, monsterID, deckID uint64) ([]BattlePhase, error) {
|
||||
var monster []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM FieldMonsterTable WHERE id=?", monsterID).Scan(&monster); err != nil {
|
||||
return nil, fmt.Errorf("gamedata: phase monster %d: %w", monsterID, err)
|
||||
}
|
||||
group, err := phaseScalar(monster, 21)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if group == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if group > 2147483647 {
|
||||
return nil, fmt.Errorf("gamedata: phase group exceeds int32")
|
||||
}
|
||||
rows, err := db.Query("SELECT id, ProtoBuf FROM PhaseBattleTable WHERE groupId=? ORDER BY id", group)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var phases []BattlePhase
|
||||
selected := false
|
||||
for rows.Next() {
|
||||
var data []byte
|
||||
var sqlID uint64
|
||||
if err := rows.Scan(&sqlID, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rowGroup, err := phaseScalar(data, 3)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := phaseScalar(data, 4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deck, err := phaseScalar(data, 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rowGroup != group || id == 0 || deck == 0 || id > 2147483647 || deck > 2147483647 || id != sqlID || (len(phases) > 0 && phases[len(phases)-1].ID >= id) {
|
||||
return nil, fmt.Errorf("gamedata: invalid phase group %d row %d deck %d", rowGroup, id, deck)
|
||||
}
|
||||
phases = append(phases, BattlePhase{GroupID: group, ID: id, DeckID: deck})
|
||||
selected = selected || deck == deckID
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(phases) != 0 && !selected {
|
||||
// Phase rows name the normal decks. Hard story battles use the
|
||||
// independently authored difficulty decks for every phase.
|
||||
var data []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM BattleDeckTable WHERE id=?", deckID).Scan(&data); err != nil {
|
||||
return nil, fmt.Errorf("gamedata: selected phase deck %d: %w", deckID, err)
|
||||
}
|
||||
difficulty, err := phaseScalar(data, 24)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if difficulty == 0 {
|
||||
return nil, fmt.Errorf("gamedata: phase group %d does not contain selected deck %d", group, deckID)
|
||||
}
|
||||
for i := range phases {
|
||||
mapped, err := battleDeckForDifficultyFromDB(db, phases[i].DeckID, difficulty)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
phases[i].DeckID = mapped
|
||||
selected = selected || mapped == deckID
|
||||
}
|
||||
}
|
||||
if len(phases) == 0 || !selected {
|
||||
return nil, fmt.Errorf("gamedata: phase group %d does not contain selected deck %d", group, deckID)
|
||||
}
|
||||
return phases, nil
|
||||
}
|
||||
|
||||
func phaseScalar(data []byte, number int) (uint64, error) {
|
||||
var value uint64
|
||||
err := wire.Walk(data, func(f wire.Field) error {
|
||||
if f.Number != number {
|
||||
return nil
|
||||
}
|
||||
if f.Type != 0 {
|
||||
return fmt.Errorf("gamedata: phase field %d has wire type %d", number, f.Type)
|
||||
}
|
||||
values, err := packedInts(data, number)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(values) != 1 {
|
||||
return fmt.Errorf("gamedata: phase field %d is not scalar", number)
|
||||
}
|
||||
value = values[0]
|
||||
return nil
|
||||
})
|
||||
return value, err
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package gamedata
|
||||
|
||||
import "fmt"
|
||||
|
||||
// BuffItem is a permanent account-stat reward, not an inventory entity.
|
||||
func LoadBuffRewardDesign(root, version string) (map[uint64]PictorialBuffStat, error) {
|
||||
pictorial, err := LoadPictorialDesign(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, done, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
rows, err := db.Query("SELECT ProtoBuf FROM BuffItemTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := map[uint64]PictorialBuffStat{}
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := optionalScalar(raw, 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buffID, err := optionalScalar(raw, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buff, ok := pictorial.Buffs[buffID]
|
||||
if !ok || buff.Category != 1 {
|
||||
return nil, fmt.Errorf("gamedata: buff item %d permanent buff %d unavailable", id, buffID)
|
||||
}
|
||||
out[id] = PictorialBuffStat{Category: buff.Category, StatType: buff.StatType, Value: buff.Value}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -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 func() { _ = 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,46 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type CashBonusReward struct {
|
||||
ID, RequireCount uint64
|
||||
Reward Reward
|
||||
}
|
||||
|
||||
type CashBonusCatalog struct{ Groups map[uint64][]CashBonusReward }
|
||||
|
||||
func LoadCashBonusCatalog(root, version string) (*CashBonusCatalog, error) {
|
||||
db, done, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
d := &CashBonusCatalog{Groups: map[uint64][]CashBonusReward{}}
|
||||
err = readCashMetadata(db, "CashBonusTable", func(raw []byte) error {
|
||||
v, err := cashScalars(raw, 1, 2, 3, 6, 5, 4)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v[0] == 0 || v[1] == 0 || v[2] == 0 || v[3] == 0 || v[5] == 0 {
|
||||
return fmt.Errorf("gamedata: invalid cash bonus")
|
||||
}
|
||||
d.Groups[v[0]] = append(d.Groups[v[0]], CashBonusReward{v[1], v[2], Reward{Type: v[3], ID: v[4], Count: v[5]}})
|
||||
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, row := range rows {
|
||||
if i > 0 && row.ID == rows[i-1].ID {
|
||||
return nil, fmt.Errorf("gamedata: duplicate cash bonus group=%d id=%d", group, row.ID)
|
||||
}
|
||||
}
|
||||
d.Groups[group] = rows
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -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,26 @@
|
||||
package gamedata
|
||||
|
||||
import "fmt"
|
||||
|
||||
// LoadCashMailTemplates shares the decrypted GameData cache and validates the
|
||||
// localized template IDs used by RewardGroupTable.MailId.
|
||||
func LoadCashMailTemplates(root, version string) (map[uint64]bool, error) {
|
||||
db, done, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
templates := map[uint64]bool{}
|
||||
err = readCashMetadata(db, "MailInfoTable", func(raw []byte) error {
|
||||
id, err := optionalScalar(raw, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if id == 0 {
|
||||
return fmt.Errorf("gamedata: invalid cash mail template")
|
||||
}
|
||||
templates[id] = true
|
||||
return nil
|
||||
})
|
||||
return templates, err
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"bd2server/internal/server/protocol/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 EventShopDesign struct{ ID, ProductGroupID uint64 }
|
||||
type CashPackageDesign struct{ GroupID, ID, SaleGroup, PackageType, PaidShopGroupID, PaidShopID, ContentsGroupID, ContentsSortID uint64 }
|
||||
type CashCatalog struct {
|
||||
Products []CashProductDesign
|
||||
Shops []CashShopDesign
|
||||
Packages []CashPackageDesign
|
||||
EventShops []EventShopDesign
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if err = readCashMetadata(db, "EventShopTable", func(raw []byte) error {
|
||||
v, e := cashScalars(raw, 2, 5)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if v[0] == 0 || v[1] == 0 {
|
||||
return fmt.Errorf("gamedata: invalid event shop")
|
||||
}
|
||||
c.EventShops = append(c.EventShops, EventShopDesign{v[0], v[1]})
|
||||
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 func() { _ = 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,143 @@
|
||||
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
|
||||
}
|
||||
|
||||
// CashMailReward is a fully selected attachment set using the game's localized mail template.
|
||||
type CashMailReward struct {
|
||||
TemplateID uint64
|
||||
Rewards []BattleReward
|
||||
}
|
||||
type CashDelivery struct {
|
||||
Direct []BattleReward
|
||||
Mail []CashMailReward
|
||||
}
|
||||
|
||||
func (c *CashRewardResolver) ResolveGranted(rewards []BattleReward) ([]BattleReward, error) {
|
||||
plan, err := c.resolve(rewards, false)
|
||||
return plan.Direct, err
|
||||
}
|
||||
func (c *CashRewardResolver) ResolveDelivery(rewards []BattleReward) (CashDelivery, error) {
|
||||
return c.resolve(rewards, true)
|
||||
}
|
||||
func (c *CashRewardResolver) resolve(rewards []BattleReward, delivery bool) (CashDelivery, error) {
|
||||
budget := uint64(100000)
|
||||
visiting := map[uint64]bool{}
|
||||
var out CashDelivery
|
||||
appendReward := func(r BattleReward, template uint64) {
|
||||
if template == 0 {
|
||||
out.Direct = append(out.Direct, r)
|
||||
return
|
||||
}
|
||||
for i := range out.Mail {
|
||||
if out.Mail[i].TemplateID == template {
|
||||
out.Mail[i].Rewards = append(out.Mail[i].Rewards, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
out.Mail = append(out.Mail, CashMailReward{TemplateID: template, Rewards: []BattleReward{r}})
|
||||
}
|
||||
var emit func(BattleReward, bool, uint64) error
|
||||
emit = func(r BattleReward, force bool, template uint64) 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] {
|
||||
appendReward(r, template)
|
||||
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)
|
||||
}
|
||||
if delivery && template == 0 {
|
||||
var err error
|
||||
template, err = optionalScalar(raw, 7)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
for _, leaf := range selected {
|
||||
appendReward(leaf, template)
|
||||
}
|
||||
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, template); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, r := range rewards {
|
||||
if err := emit(r, true, 0); err != nil {
|
||||
return CashDelivery{}, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// CharAwakeCost is one exact CharAwakeGrowthTable material row. Type 4 is
|
||||
// gold (and therefore has ID 0); type 8 is a mutable ResourceTable item.
|
||||
type CharAwakeCost struct{ Type, ID, Count uint64 }
|
||||
|
||||
// CharAwakeGrowth is one cumulative imprint level or one awakening effect.
|
||||
// Imprint StatValue is the value at that target level, not a per-level delta.
|
||||
type CharAwakeGrowth struct {
|
||||
ID uint64
|
||||
Costs []CharAwakeCost
|
||||
StatType uint64
|
||||
StatValue float64
|
||||
}
|
||||
|
||||
type CharAwakeCharacter struct {
|
||||
UniqueCharID uint64
|
||||
Active bool
|
||||
ImprintIDs [3]uint64
|
||||
ImprintGrowth [3][]CharAwakeGrowth
|
||||
AwakeGrowth []CharAwakeGrowth
|
||||
}
|
||||
|
||||
type CharAwakeCharacterStage struct {
|
||||
UniqueCharID uint64
|
||||
Grade uint64
|
||||
GrowthGrade uint64
|
||||
MaximumLevel uint64
|
||||
}
|
||||
|
||||
// CharAwakeDesign is immutable 2.35.10 design data. Account progress remains
|
||||
// in CollectionStore and is indexed by UniqueCharId, as CharAwakeDBInfo is.
|
||||
type CharAwakeDesign struct {
|
||||
Characters map[uint64]CharAwakeCharacter
|
||||
Stages map[uint64]CharAwakeCharacterStage
|
||||
}
|
||||
|
||||
type CharImprintTarget struct {
|
||||
Slot uint64
|
||||
TargetLevel uint64
|
||||
}
|
||||
|
||||
func LoadCharAwakeDesign(root, version string) (*CharAwakeDesign, error) {
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
return loadCharAwakeDesign(db)
|
||||
}
|
||||
|
||||
func loadCharAwakeDesign(db *sql.DB) (*CharAwakeDesign, error) {
|
||||
growth := make(map[uint64]CharAwakeGrowth)
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM CharAwakeGrowthTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
counts, _ := packedInts(proto, 1)
|
||||
ids, _ := packedInts(proto, 2)
|
||||
types, _ := packedInts(proto, 3)
|
||||
protoID, _ := packedInts(proto, 4)
|
||||
statType, _ := packedInts(proto, 5)
|
||||
statValue, hasStatValue, err := fixed64Double(proto, 6)
|
||||
if err != nil || len(protoID) != 1 || protoID[0] != id || len(statType) != 1 || !hasStatValue || math.IsNaN(statValue) || math.IsInf(statValue, 0) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid CharAwakeGrowthTable row %d", id)
|
||||
}
|
||||
if len(counts) != len(ids) || len(counts) != len(types) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: mismatched CharAwakeGrowthTable costs %d", id)
|
||||
}
|
||||
entry := CharAwakeGrowth{ID: id, StatType: statType[0], StatValue: statValue}
|
||||
if entry.StatType == 0 || entry.StatType > 20 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: unsupported awakening stat type %d in row %d", entry.StatType, id)
|
||||
}
|
||||
for i := range counts {
|
||||
if counts[i] == 0 || (types[i] != 4 && types[i] != 8) || (types[i] == 4 && ids[i] != 0) || (types[i] == 8 && ids[i] == 0) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid awakening cost in row %d", id)
|
||||
}
|
||||
entry.Costs = append(entry.Costs, CharAwakeCost{Type: types[i], ID: ids[i], Count: counts[i]})
|
||||
}
|
||||
growth[id] = entry
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
imprints := make(map[uint64][]CharAwakeGrowth)
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM CharImprintTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
growthIDs, _ := packedInts(proto, 1)
|
||||
protoID, _ := packedInts(proto, 2)
|
||||
if len(protoID) != 1 || protoID[0] != id || len(growthIDs) == 0 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid CharImprintTable row %d", id)
|
||||
}
|
||||
for _, growthID := range growthIDs {
|
||||
entry, ok := growth[growthID]
|
||||
if !ok || len(entry.Costs) == 0 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: imprint %d has invalid growth %d", id, growthID)
|
||||
}
|
||||
imprints[id] = append(imprints[id], entry)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
design := &CharAwakeDesign{Characters: map[uint64]CharAwakeCharacter{}, Stages: map[uint64]CharAwakeCharacterStage{}}
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM CharAwakeTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var rowID uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&rowID, &proto); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
active, _ := packedInts(proto, 1)
|
||||
awakeIDs, _ := packedInts(proto, 2)
|
||||
unique, _ := packedInts(proto, 3)
|
||||
slot1, _ := packedInts(proto, 4)
|
||||
slot2, _ := packedInts(proto, 5)
|
||||
slot3, _ := packedInts(proto, 6)
|
||||
if len(active) != 1 || active[0] != 1 || len(unique) != 1 || unique[0] != rowID || len(slot1) != 1 || len(slot2) != 1 || len(slot3) != 1 || len(awakeIDs) == 0 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid CharAwakeTable row %d", rowID)
|
||||
}
|
||||
entry := CharAwakeCharacter{UniqueCharID: rowID, Active: true, ImprintIDs: [3]uint64{slot1[0], slot2[0], slot3[0]}}
|
||||
for i, imprintID := range entry.ImprintIDs {
|
||||
levels, ok := imprints[imprintID]
|
||||
if !ok {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: awakening %d has unknown imprint %d", rowID, imprintID)
|
||||
}
|
||||
entry.ImprintGrowth[i] = append([]CharAwakeGrowth(nil), levels...)
|
||||
}
|
||||
for i, growthID := range awakeIDs {
|
||||
awakeGrowth, ok := growth[growthID]
|
||||
if !ok || (i > 0 && len(awakeGrowth.Costs) != 0) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: awakening %d has invalid growth %d", rowID, growthID)
|
||||
}
|
||||
entry.AwakeGrowth = append(entry.AwakeGrowth, awakeGrowth)
|
||||
}
|
||||
if len(entry.AwakeGrowth[0].Costs) == 0 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: awakening %d has no activation cost", rowID)
|
||||
}
|
||||
design.Characters[rowID] = entry
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type stageRow struct {
|
||||
id uint64
|
||||
proto []byte
|
||||
}
|
||||
var sourceRows []stageRow
|
||||
for rows.Next() {
|
||||
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)
|
||||
unique, _ := packedInts(proto, 20)
|
||||
if len(unique) != 1 || design.Characters[unique[0]].UniqueCharID == 0 {
|
||||
continue
|
||||
}
|
||||
if len(growthID) != 1 || len(grade) != 1 || len(growthGrade) != 1 {
|
||||
return nil, fmt.Errorf("gamedata: invalid awakening character stage %d", id)
|
||||
}
|
||||
var growthProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthID[0]).Scan(&growthProto); err != nil {
|
||||
return nil, fmt.Errorf("gamedata: awakening character %d growth: %w", id, err)
|
||||
}
|
||||
maximum, _ := packedInts(growthProto, 9)
|
||||
if len(maximum) != 1 || maximum[0] == 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid awakening character maximum %d", id)
|
||||
}
|
||||
design.Stages[id] = CharAwakeCharacterStage{UniqueCharID: unique[0], Grade: grade[0], GrowthGrade: growthGrade[0], MaximumLevel: maximum[0]}
|
||||
}
|
||||
if len(design.Characters) == 0 || len(design.Stages) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: awakening design is empty")
|
||||
}
|
||||
return design, nil
|
||||
}
|
||||
|
||||
func (d *CharAwakeDesign) CharacterUniqueID(characterID uint64) (uint64, bool) {
|
||||
if d == nil {
|
||||
return 0, false
|
||||
}
|
||||
stage, ok := d.Stages[characterID]
|
||||
return stage.UniqueCharID, ok
|
||||
}
|
||||
|
||||
// ValidateGrowthCompleted mirrors CommonPacket.IsCharGrowthLevelCompleted:
|
||||
// the current CharTable stage must have Growthgrade == Grade and its own
|
||||
// CharGrowthTable.MaxLevel must have been reached.
|
||||
func (d *CharAwakeDesign) ValidateGrowthCompleted(characterID, level uint64) (uint64, error) {
|
||||
if d == nil {
|
||||
return 0, fmt.Errorf("gamedata: awakening design unavailable")
|
||||
}
|
||||
stage, ok := d.Stages[characterID]
|
||||
if !ok || stage.UniqueCharID == 0 {
|
||||
return 0, fmt.Errorf("gamedata: character %d has no awakening design", characterID)
|
||||
}
|
||||
if stage.Grade != stage.GrowthGrade || level < stage.MaximumLevel {
|
||||
return 0, fmt.Errorf("gamedata: character %d has not completed growth", characterID)
|
||||
}
|
||||
return stage.UniqueCharID, nil
|
||||
}
|
||||
|
||||
func appendAwakeCosts(dst []CharAwakeCost, src []CharAwakeCost) ([]CharAwakeCost, error) {
|
||||
for _, cost := range src {
|
||||
merged := false
|
||||
for i := range dst {
|
||||
if dst[i].Type != cost.Type || dst[i].ID != cost.ID {
|
||||
continue
|
||||
}
|
||||
if cost.Count > ^uint64(0)-dst[i].Count {
|
||||
return nil, fmt.Errorf("gamedata: awakening cost overflow")
|
||||
}
|
||||
dst[i].Count += cost.Count
|
||||
merged = true
|
||||
break
|
||||
}
|
||||
if !merged {
|
||||
dst = append(dst, cost)
|
||||
}
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func (d *CharAwakeDesign) ImprintCosts(uniqueCharID uint64, current [3]uint64, targets []CharImprintTarget) ([]CharAwakeCost, [3]uint64, error) {
|
||||
entry, ok := d.Characters[uniqueCharID]
|
||||
if !ok || !entry.Active || len(targets) == 0 {
|
||||
return nil, current, fmt.Errorf("gamedata: invalid imprint request for character %d", uniqueCharID)
|
||||
}
|
||||
next := current
|
||||
seen := [3]bool{}
|
||||
var costs []CharAwakeCost
|
||||
for _, target := range targets {
|
||||
if target.Slot < 1 || target.Slot > 3 || seen[target.Slot-1] {
|
||||
return nil, current, fmt.Errorf("gamedata: invalid or duplicate imprint slot %d", target.Slot)
|
||||
}
|
||||
seen[target.Slot-1] = true
|
||||
slot := target.Slot - 1
|
||||
maximum := uint64(len(entry.ImprintGrowth[slot]))
|
||||
if target.TargetLevel <= current[slot] || target.TargetLevel > maximum {
|
||||
return nil, current, fmt.Errorf("gamedata: invalid imprint target %d for slot %d at %d/%d", target.TargetLevel, target.Slot, current[slot], maximum)
|
||||
}
|
||||
for level := current[slot] + 1; level <= target.TargetLevel; level++ {
|
||||
var err error
|
||||
costs, err = appendAwakeCosts(costs, entry.ImprintGrowth[slot][level-1].Costs)
|
||||
if err != nil {
|
||||
return nil, current, err
|
||||
}
|
||||
}
|
||||
next[slot] = target.TargetLevel
|
||||
}
|
||||
return costs, next, nil
|
||||
}
|
||||
|
||||
func (d *CharAwakeDesign) AwakeCosts(uniqueCharID uint64, levels [3]uint64, isAwake bool) ([]CharAwakeCost, error) {
|
||||
entry, ok := d.Characters[uniqueCharID]
|
||||
if !ok || !entry.Active || isAwake {
|
||||
return nil, fmt.Errorf("gamedata: character %d cannot awaken", uniqueCharID)
|
||||
}
|
||||
for i := range levels {
|
||||
if levels[i] != uint64(len(entry.ImprintGrowth[i])) {
|
||||
return nil, fmt.Errorf("gamedata: character %d imprint slot %d is not complete", uniqueCharID, i+1)
|
||||
}
|
||||
}
|
||||
return append([]CharAwakeCost(nil), entry.AwakeGrowth[0].Costs...), nil
|
||||
}
|
||||
|
||||
func (d *CharAwakeDesign) ValidateProgress(uniqueCharID uint64, levels [3]uint64, isAwake bool) error {
|
||||
entry, ok := d.Characters[uniqueCharID]
|
||||
if !ok || !entry.Active {
|
||||
return fmt.Errorf("gamedata: character %d has no awakening design", uniqueCharID)
|
||||
}
|
||||
for i, level := range levels {
|
||||
maximum := uint64(len(entry.ImprintGrowth[i]))
|
||||
if level > maximum || (isAwake && level != maximum) {
|
||||
return fmt.Errorf("gamedata: invalid saved awakening slot %d level %d/%d", i+1, level, maximum)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CharAwakeContributions reproduces the client aggregation: each imprint slot
|
||||
// contributes only its target-level cumulative row, while awakening adds all
|
||||
// CharAwakeTable.GrowthId stat rows.
|
||||
func (d *CharAwakeDesign) CharAwakeContributions(uniqueCharID uint64, levels [3]uint64, isAwake bool) ([]StatContribution, error) {
|
||||
if err := d.ValidateProgress(uniqueCharID, levels, isAwake); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry := d.Characters[uniqueCharID]
|
||||
var growth []CharAwakeGrowth
|
||||
for i, level := range levels {
|
||||
if level > uint64(len(entry.ImprintGrowth[i])) {
|
||||
return nil, fmt.Errorf("gamedata: imprint slot %d level %d exceeds maximum", i+1, level)
|
||||
}
|
||||
if level != 0 {
|
||||
growth = append(growth, entry.ImprintGrowth[i][level-1])
|
||||
}
|
||||
}
|
||||
if isAwake {
|
||||
growth = append(growth, entry.AwakeGrowth...)
|
||||
}
|
||||
result := make([]StatContribution, 0, len(growth))
|
||||
for _, row := range growth {
|
||||
contribution := StatContribution{Option: row.StatType}
|
||||
switch row.StatType {
|
||||
case 1, 2:
|
||||
contribution.Stat = StatHealth
|
||||
case 3, 4:
|
||||
contribution.Stat = StatAttack
|
||||
case 5, 6:
|
||||
contribution.Stat = StatMagic
|
||||
case 7:
|
||||
contribution.Stat = StatDefencePercent
|
||||
case 8:
|
||||
contribution.Stat = StatMagicResistancePercent
|
||||
case 9:
|
||||
contribution.Stat = StatCriticalChance
|
||||
case 10:
|
||||
contribution.Stat = StatCriticalDamage
|
||||
case 11, 12, 13, 14, 15, 19:
|
||||
contribution.Stat = StatElementDamage
|
||||
case 16, 17, 18, 20:
|
||||
contribution.Stat = StatElementResistance
|
||||
default:
|
||||
return nil, fmt.Errorf("gamedata: unsupported awakening stat option %d", row.StatType)
|
||||
}
|
||||
if row.StatType == 2 || row.StatType == 4 || row.StatType == 6 {
|
||||
contribution.Percent = row.StatValue
|
||||
} else {
|
||||
contribution.Flat = row.StatValue
|
||||
}
|
||||
result = append(result, contribution)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ContentOpeningDesign describes the rapport prologue's prerequisite and
|
||||
// completion marker. The client maps ContentOpenRequest type 1 to group 23,
|
||||
// detail 2; ticket identities and level requirements remain version data.
|
||||
type ContentOpeningDesign struct {
|
||||
Prerequisite, Completion ContentOpenRule
|
||||
}
|
||||
|
||||
func LoadContentOpeningDesign(root, version string) (*ContentOpeningDesign, error) {
|
||||
db, done, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
return loadContentOpeningDesign(db)
|
||||
}
|
||||
|
||||
func loadContentOpeningDesign(db *sql.DB) (*ContentOpeningDesign, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("gamedata: nil content opening database")
|
||||
}
|
||||
tickets := map[uint64]uint64{}
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM ContentTicketTable 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 {
|
||||
break
|
||||
}
|
||||
var protoID, typ uint64
|
||||
protoID, err = friendshipScalar(raw, 4)
|
||||
if err == nil {
|
||||
typ, err = friendshipScalar(raw, 8)
|
||||
}
|
||||
if err != nil || id == 0 || protoID != id || typ < 1 || typ > 6 {
|
||||
err = fmt.Errorf("gamedata: invalid content ticket %d", id)
|
||||
break
|
||||
}
|
||||
if _, exists := tickets[id]; exists {
|
||||
err = fmt.Errorf("gamedata: duplicate content ticket %d", id)
|
||||
break
|
||||
}
|
||||
tickets[id] = typ
|
||||
}
|
||||
if err == nil {
|
||||
err = rows.Err()
|
||||
}
|
||||
_ = rows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT groupId,id,ticketId,ProtoBuf FROM ContentOpenTable ORDER BY groupId,id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
d := &ContentOpeningDesign{}
|
||||
seen := map[[2]uint64]bool{}
|
||||
for rows.Next() {
|
||||
var group, id, ticket uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&group, &id, &ticket, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := make([]uint64, 6)
|
||||
for i := range values {
|
||||
values[i], err = friendshipScalar(raw, i+1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: content opening %d/%d: %w", group, id, err)
|
||||
}
|
||||
}
|
||||
key := [2]uint64{group, id}
|
||||
if group == 0 || id == 0 || values[0] != group || values[1] != id || ticket == 0 || values[5] != ticket || tickets[ticket] == 0 || seen[key] {
|
||||
return nil, fmt.Errorf("gamedata: invalid content opening %d/%d identity or ticket", group, id)
|
||||
}
|
||||
seen[key] = true
|
||||
if group != 23 || (id != 1 && id != 2) {
|
||||
continue
|
||||
}
|
||||
if tickets[ticket] != 1 {
|
||||
return nil, fmt.Errorf("gamedata: rapport opening %d requires permanent content ticket", id)
|
||||
}
|
||||
rule := ContentOpenRule{TutorialID: values[3], SquadLevel: values[4], TicketID: ticket}
|
||||
if id == 1 {
|
||||
d.Prerequisite = rule
|
||||
} else {
|
||||
d.Completion = rule
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !seen[[2]uint64{23, 1}] || !seen[[2]uint64{23, 2}] || d.Prerequisite.TicketID == d.Completion.TicketID {
|
||||
return nil, fmt.Errorf("gamedata: missing or indistinguishable rapport opening rules")
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package gamedata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type GachaContentTicketDesign struct{ IDs map[uint64]bool }
|
||||
|
||||
func LoadGachaContentTicketDesign(root, version string) (*GachaContentTicketDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM ContentTicketTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
d := &GachaContentTicketDesign{IDs: map[uint64]bool{}}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typ, err := optionalScalar(raw, 8)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if typ == 4 {
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid gacha content ticket")
|
||||
}
|
||||
d.IDs[id] = true
|
||||
}
|
||||
}
|
||||
return d, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// CookingRecipeDesign describes valid recipe identities. Learning a recipe is
|
||||
// player state, so listing this table never grants all of its recipes.
|
||||
type CookingRecipeDesign struct{ IDs map[uint64]bool }
|
||||
|
||||
func LoadCookingRecipeDesign(root, version string) (*CookingRecipeDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
return loadCookingRecipeDesign(db)
|
||||
}
|
||||
|
||||
func loadCookingRecipeDesign(db *sql.DB) (*CookingRecipeDesign, error) {
|
||||
rows, err := db.Query("SELECT id FROM CookingTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
design := &CookingRecipeDesign{IDs: map[uint64]bool{}}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id <= 0 || id > math.MaxInt32 || design.IDs[uint64(id)] {
|
||||
return nil, fmt.Errorf("gamedata: invalid cooking recipe identity")
|
||||
}
|
||||
design.IDs[uint64(id)] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(design.IDs) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: empty cooking recipe catalog")
|
||||
}
|
||||
return design, nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CostumeBurstLevel is one row of CostumeBurstTable. Level is the database
|
||||
// row id (and is the level unlocked by upgrading from Level-1).
|
||||
type CostumeBurstLevel struct {
|
||||
CostumeID uint64
|
||||
Level uint64
|
||||
Costs []PromotionCost
|
||||
}
|
||||
|
||||
// CostumeBurstUpgradeRule is the exact cost for the next burst level.
|
||||
type CostumeBurstUpgradeRule struct {
|
||||
CostumeID uint64
|
||||
CurrentLevel uint64
|
||||
NextLevel uint64
|
||||
MaxLevel uint64
|
||||
Costs []PromotionCost
|
||||
}
|
||||
|
||||
// CostumeBurstDesign contains only costumes which have a complete, contiguous
|
||||
// CostumeBurstTable group. GroupId is the CostumeTable id for burst-enabled
|
||||
// costumes (the table also carries CostumeType for newer costume families).
|
||||
type CostumeBurstDesign struct {
|
||||
Levels map[uint64]map[uint64]CostumeBurstLevel
|
||||
}
|
||||
|
||||
func LoadCostumeBurstDesign(root, version string) (*CostumeBurstDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
return loadCostumeBurstDesign(db)
|
||||
}
|
||||
|
||||
func loadCostumeBurstDesign(db *sql.DB) (*CostumeBurstDesign, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("gamedata: nil costume burst database")
|
||||
}
|
||||
design := &CostumeBurstDesign{Levels: make(map[uint64]map[uint64]CostumeBurstLevel)}
|
||||
rows, err := db.Query("SELECT groupId,id,ProtoBuf FROM CostumeBurstTable ORDER BY groupId,id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var groupID, level uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&groupID, &level, &raw); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if groupID == 0 || level == 0 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid costume burst identity %d/%d", groupID, level)
|
||||
}
|
||||
groups, groupErr := packedInts(raw, 8)
|
||||
ids, idErr := packedInts(raw, 9)
|
||||
counts, countErr := packedInts(raw, 10)
|
||||
itemIDs, itemIDErr := packedInts(raw, 11)
|
||||
types, typeErr := packedInts(raw, 12)
|
||||
if groupErr != nil || idErr != nil || countErr != nil || itemIDErr != nil || typeErr != nil {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: costume burst %d/%d has invalid fields", groupID, level)
|
||||
}
|
||||
if len(groups) != 1 || groups[0] != groupID || len(ids) != 1 || ids[0] != level {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: costume burst %d/%d has mismatched proto identity", groupID, level)
|
||||
}
|
||||
if len(counts) == 0 || len(counts) != len(itemIDs) || len(itemIDs) != len(types) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: costume burst %d/%d has mismatched costs", groupID, level)
|
||||
}
|
||||
entry := CostumeBurstLevel{CostumeID: groupID, Level: level}
|
||||
for i := range counts {
|
||||
if counts[i] == 0 || (types[i] == 4 && itemIDs[i] != 0) ||
|
||||
(types[i] == 8 && itemIDs[i] == 0) || (types[i] != 4 && types[i] != 8) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: costume burst %d/%d has invalid cost", groupID, level)
|
||||
}
|
||||
entry.Costs = append(entry.Costs, PromotionCost{Type: types[i], ID: itemIDs[i], Count: counts[i]})
|
||||
}
|
||||
if _, exists := design.Levels[groupID][level]; exists {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: duplicate costume burst %d/%d", groupID, level)
|
||||
}
|
||||
if design.Levels[groupID] == nil {
|
||||
design.Levels[groupID] = make(map[uint64]CostumeBurstLevel)
|
||||
}
|
||||
design.Levels[groupID][level] = entry
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(design.Levels) == 0 {
|
||||
return nil, errors.New("gamedata: CostumeBurstTable is empty")
|
||||
}
|
||||
for costumeID, levels := range design.Levels {
|
||||
for level := uint64(1); level <= uint64(len(levels)); level++ {
|
||||
if _, ok := levels[level]; !ok {
|
||||
return nil, fmt.Errorf("gamedata: costume burst %d missing level %d", costumeID, level)
|
||||
}
|
||||
}
|
||||
}
|
||||
return design, nil
|
||||
}
|
||||
|
||||
// UpgradeRule returns the costs for currentLevel -> currentLevel+1.
|
||||
func (d *CostumeBurstDesign) UpgradeRule(costumeID, currentLevel uint64) (CostumeBurstUpgradeRule, error) {
|
||||
if d == nil || costumeID == 0 {
|
||||
return CostumeBurstUpgradeRule{}, errors.New("gamedata: invalid costume burst lookup")
|
||||
}
|
||||
levels, ok := d.Levels[costumeID]
|
||||
if !ok {
|
||||
return CostumeBurstUpgradeRule{}, fmt.Errorf("gamedata: costume %d has no burst design", costumeID)
|
||||
}
|
||||
maxLevel := uint64(len(levels))
|
||||
if currentLevel >= maxLevel {
|
||||
return CostumeBurstUpgradeRule{}, fmt.Errorf("gamedata: costume %d burst is already at maximum level %d", costumeID, maxLevel)
|
||||
}
|
||||
next := currentLevel + 1
|
||||
entry, ok := levels[next]
|
||||
if !ok {
|
||||
return CostumeBurstUpgradeRule{}, fmt.Errorf("gamedata: costume %d missing burst level %d", costumeID, next)
|
||||
}
|
||||
return CostumeBurstUpgradeRule{
|
||||
CostumeID: costumeID, CurrentLevel: currentLevel, NextLevel: next,
|
||||
MaxLevel: maxLevel, Costs: append([]PromotionCost(nil), entry.Costs...),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
type CostumePotentialCost struct{ Type, ID, Count uint64 }
|
||||
|
||||
type CostumePotentialNode struct {
|
||||
ID uint64
|
||||
NodeType uint64
|
||||
StatType uint64
|
||||
StatValue float64
|
||||
ConditionGrade uint64
|
||||
Prerequisites []uint64
|
||||
Costs []CostumePotentialCost
|
||||
}
|
||||
|
||||
type CostumePotentialDesign struct {
|
||||
Nodes map[uint64]map[uint64]CostumePotentialNode
|
||||
CostumeUnique map[uint64]uint64
|
||||
CharacterGrade map[uint64]uint64
|
||||
CharacterUnique map[uint64]uint64
|
||||
CharacterTypes map[uint64]uint64
|
||||
CostumeActive map[uint64]bool
|
||||
}
|
||||
|
||||
func LoadCostumePotentialDesign(root, version string) (*CostumePotentialDesign, error) {
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
return loadCostumePotentialDesign(db)
|
||||
}
|
||||
|
||||
func loadCostumePotentialDesign(db *sql.DB) (*CostumePotentialDesign, error) {
|
||||
d := &CostumePotentialDesign{Nodes: map[uint64]map[uint64]CostumePotentialNode{}, CostumeUnique: map[uint64]uint64{}, CharacterGrade: map[uint64]uint64{}, CharacterUnique: map[uint64]uint64{}}
|
||||
d.CharacterTypes = map[uint64]uint64{}
|
||||
d.CostumeActive = map[uint64]bool{}
|
||||
groups, err := db.Query("SELECT id,ProtoBuf FROM CostumeNodeGroupTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for groups.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err = groups.Scan(&id, &raw); err != nil {
|
||||
_ = groups.Close()
|
||||
return nil, err
|
||||
}
|
||||
active, e := optionalScalar(raw, 3)
|
||||
if e != nil || active > 1 {
|
||||
_ = groups.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid potential active group")
|
||||
}
|
||||
d.CostumeActive[id] = active == 1
|
||||
}
|
||||
if err = groups.Err(); err != nil {
|
||||
_ = groups.Close()
|
||||
return nil, err
|
||||
}
|
||||
_ = groups.Close()
|
||||
rows, err := db.Query("SELECT groupId,id,ProtoBuf FROM CostumeNodeTable ORDER BY groupId,id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var groupID, id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&groupID, &id, &proto); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
counts, _ := packedInts(proto, 1)
|
||||
ids, _ := packedInts(proto, 2)
|
||||
types, _ := packedInts(proto, 3)
|
||||
if len(counts) == 0 || len(counts) != len(ids) || len(counts) != len(types) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid costume potential costs %d/%d", groupID, id)
|
||||
}
|
||||
grade, _ := packedInts(proto, 17)
|
||||
conditions, _ := packedInts(proto, 18)
|
||||
node := CostumePotentialNode{ID: id, Prerequisites: append([]uint64(nil), conditions...)}
|
||||
nodeTypes, nodeTypeErr := packedInts(proto, 27)
|
||||
statTypes, statTypeErr := packedInts(proto, 29)
|
||||
statValue, _, statValueErr := fixed64Double(proto, 30)
|
||||
if nodeTypeErr != nil || statTypeErr != nil || statValueErr != nil || len(nodeTypes) != 1 || len(statTypes) > 1 || math.IsNaN(statValue) || math.IsInf(statValue, 0) || statValue < 0 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid costume potential stats %d/%d", groupID, id)
|
||||
}
|
||||
node.NodeType = nodeTypes[0]
|
||||
node.StatValue = statValue
|
||||
if len(statTypes) == 1 {
|
||||
node.StatType = statTypes[0]
|
||||
}
|
||||
if (node.NodeType == 1 || node.NodeType == 2) && (node.StatType == 0 || node.StatType > 20) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: unsupported costume potential stat %d/%d option %d", groupID, id, node.StatType)
|
||||
}
|
||||
if len(grade) == 1 {
|
||||
node.ConditionGrade = grade[0]
|
||||
} else if len(grade) > 1 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid costume potential grade %d/%d", groupID, id)
|
||||
}
|
||||
for i := range counts {
|
||||
if counts[i] == 0 || types[i] == 0 || (types[i] != 4 && ids[i] == 0) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid costume potential cost %d/%d", groupID, id)
|
||||
}
|
||||
node.Costs = append(node.Costs, CostumePotentialCost{Type: types[i], ID: ids[i], Count: counts[i]})
|
||||
}
|
||||
if d.Nodes[groupID] == nil {
|
||||
d.Nodes[groupID] = map[uint64]CostumePotentialNode{}
|
||||
}
|
||||
d.Nodes[groupID][id] = node
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM CostumeTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
unique, _ := packedInts(proto, 27)
|
||||
if len(unique) == 1 {
|
||||
d.CostumeUnique[id] = unique[0]
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
grade, _ := packedInts(proto, 10)
|
||||
typeID, e := optionalScalar(proto, 19)
|
||||
if e != nil {
|
||||
_ = rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
d.CharacterTypes[id] = typeID
|
||||
unique, _ := packedInts(proto, 20)
|
||||
if len(grade) == 1 && len(unique) == 1 {
|
||||
d.CharacterGrade[id] = grade[0]
|
||||
d.CharacterUnique[id] = unique[0]
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(d.Nodes) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: CostumeNodeTable is empty")
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *CostumePotentialDesign) Validate(costumeID, characterID, grade uint64, active, requested []uint64) ([]CostumePotentialCost, error) {
|
||||
if d == nil || costumeID == 0 || characterID == 0 || len(requested) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid costume potential request")
|
||||
}
|
||||
if d.CostumeUnique[costumeID] == 0 || d.CostumeUnique[costumeID] != d.CharacterUnique[characterID] {
|
||||
return nil, fmt.Errorf("gamedata: costume %d does not belong to character %d", costumeID, characterID)
|
||||
}
|
||||
if grade == 0 {
|
||||
grade = d.CharacterGrade[characterID]
|
||||
}
|
||||
known := make(map[uint64]bool, len(active)+len(requested))
|
||||
for _, id := range active {
|
||||
known[id] = true
|
||||
}
|
||||
for _, id := range requested {
|
||||
if id == 0 || known[id] {
|
||||
return nil, fmt.Errorf("gamedata: duplicate costume potential node %d", id)
|
||||
}
|
||||
known[id] = true
|
||||
}
|
||||
var costs []CostumePotentialCost
|
||||
for _, id := range requested {
|
||||
node, exists := d.Nodes[costumeID][id]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("gamedata: costume %d has no potential node %d", costumeID, id)
|
||||
}
|
||||
if node.ConditionGrade > grade {
|
||||
return nil, fmt.Errorf("gamedata: potential node %d requires growth grade %d", id, node.ConditionGrade)
|
||||
}
|
||||
for _, prerequisite := range node.Prerequisites {
|
||||
if !known[prerequisite] {
|
||||
return nil, fmt.Errorf("gamedata: potential node %d requires node %d", id, prerequisite)
|
||||
}
|
||||
}
|
||||
costs = append(costs, node.Costs...)
|
||||
}
|
||||
return costs, nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"bd2server/internal/server/design/dbcrypt"
|
||||
)
|
||||
|
||||
// DatabaseName maps a logical client DB name to its GameData archive entry.
|
||||
// Version 1 is the current 2.35.10 DB schema generation.
|
||||
func DatabaseName(logical string) (string, error) {
|
||||
if logical == "" || strings.ContainsAny(logical, `/\\.`) {
|
||||
return "", fmt.Errorf("gamedata: invalid logical database name %q", logical)
|
||||
}
|
||||
digest := sha1.Sum([]byte(logical + "_v1"))
|
||||
return fmt.Sprintf("%X", digest), nil
|
||||
}
|
||||
|
||||
// ReadDatabase extracts and decrypts one SQLite database from the validated
|
||||
// common-dbdata.bin. The returned bytes are an ordinary SQLite file.
|
||||
func ReadDatabase(root, version, logical string) ([]byte, error) {
|
||||
name, err := DatabaseName(logical)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return readEntry(root, version, name, logical)
|
||||
}
|
||||
|
||||
// ReadQuestDatabase extracts the shared common database using the client's
|
||||
// logical database naming rule. Quest tables are not per-pack databases.
|
||||
func ReadQuestDatabase(root, version string) ([]byte, error) {
|
||||
return ReadDatabase(root, version, "common")
|
||||
}
|
||||
|
||||
func readEntry(root, version, name, label string) ([]byte, error) {
|
||||
archivePath := filepath.Join(root, version, "release", ArchiveName)
|
||||
reader, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: open database archive: %w", err)
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
for _, entry := range reader.File {
|
||||
if !strings.EqualFold(entry.Name, name) {
|
||||
continue
|
||||
}
|
||||
stream, err := entry.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: open database %s: %w", label, err)
|
||||
}
|
||||
encrypted, readErr := io.ReadAll(stream)
|
||||
closeErr := stream.Close()
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("gamedata: read database %s: %w", label, readErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("gamedata: close database %s: %w", label, closeErr)
|
||||
}
|
||||
plain, err := dbcrypt.DecryptPages(encrypted)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: decrypt database %s: %w", label, err)
|
||||
}
|
||||
if !bytes.HasPrefix(plain, dbcrypt.Header) {
|
||||
return nil, fmt.Errorf("gamedata: database %s has no SQLite header after decryption", label)
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
return nil, fmt.Errorf("gamedata: database %s entry %s not found", label, name)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
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,273 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type DispatchDesign struct {
|
||||
Group, ID, Pack, Difficulty, TypeGroup, TypeID, AP, ClearTime, Growth uint64
|
||||
GroundID uint64
|
||||
Rewards []BattleReward
|
||||
boxes map[uint64]*dispatchGroup
|
||||
}
|
||||
type dispatchEntry struct {
|
||||
Reward BattleReward
|
||||
Weight uint64
|
||||
Child *dispatchGroup
|
||||
}
|
||||
type dispatchGroup struct {
|
||||
Drop, Count uint64
|
||||
Entries []dispatchEntry
|
||||
}
|
||||
|
||||
func LoadDispatchDesign(root, version string, group, id uint64) (*DispatchDesign, error) {
|
||||
db, done, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
var raw []byte
|
||||
if err = db.QueryRow("SELECT ProtoBuf FROM HuntDispatchTable WHERE groupId=? AND id=?", group, id).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := &DispatchDesign{boxes: map[uint64]*dispatchGroup{}}
|
||||
for n, p := range map[int]*uint64{1: &d.AP, 4: &d.ClearTime, 5: &d.Difficulty, 7: &d.Group, 8: &d.ID, 10: &d.Pack, 12: &d.Growth, 13: &d.TypeGroup, 14: &d.TypeID} {
|
||||
*p, err = optionalScalar(raw, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if d.AP == 0 || d.Pack == 0 || d.Group != group || d.ID != id {
|
||||
return nil, fmt.Errorf("gamedata: invalid dispatch")
|
||||
}
|
||||
pdb, closePack, err := openPackDatabase(root, version, int(d.Pack))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closePack()
|
||||
var ids []uint64
|
||||
var boss uint64
|
||||
if d.TypeGroup == 0 {
|
||||
if err = pdb.QueryRow("SELECT ProtoBuf FROM HuntingGroundTable WHERE difficulty=?", d.Difficulty).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids, err = packedInts(raw, 5)
|
||||
boss, _ = optionalScalar(raw, 1)
|
||||
d.GroundID, _ = optionalScalar(raw, 3)
|
||||
} else {
|
||||
if err = db.QueryRow("SELECT ProtoBuf FROM SkyWayFieldTable WHERE groupId=? AND id=?", d.TypeGroup, d.TypeID).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids, err = packedInts(raw, 15)
|
||||
boss, _ = optionalScalar(raw, 4)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, boss)
|
||||
for _, monster := range ids {
|
||||
if err = pdb.QueryRow("SELECT ProtoBuf FROM FieldMonsterTable WHERE id=?", monster).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decks, e := packedInts(raw, 2)
|
||||
if e != nil || len(decks) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: dispatch monster has no deck")
|
||||
}
|
||||
if err = pdb.QueryRow("SELECT ProtoBuf FROM BattleDeckTable WHERE id=?", decks[0]).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
types, e := packedInts(raw, 36)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
rewardIDs, e := packedInts(raw, 34)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
counts, e := packedInts(raw, 33)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if len(types) != len(rewardIDs) || len(types) != len(counts) {
|
||||
return nil, fmt.Errorf("gamedata: dispatch reward arrays mismatch")
|
||||
}
|
||||
rs, e := parallelRewards(raw, 36, 34, 33)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
for _, r := range rs {
|
||||
d.Rewards = append(d.Rewards, BattleReward{r.Type, r.ID, r.Count}) //nolint:staticcheck // S1016
|
||||
if r.Type == 9 {
|
||||
g, e := loadDispatchBox(db, r.ID, map[uint64]bool{})
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
d.boxes[r.ID] = g
|
||||
}
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
func loadDispatchBox(db *sql.DB, id uint64, seen map[uint64]bool) (*dispatchGroup, error) {
|
||||
if seen[id] || len(seen) > 32 {
|
||||
return nil, fmt.Errorf("gamedata: dispatch reward cycle")
|
||||
}
|
||||
seen[id] = true
|
||||
defer delete(seen, id)
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM RandomBoxTable WHERE id=?", id).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gid, err := optionalScalar(raw, 9)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loadDispatchRewardGroup(db, gid, seen)
|
||||
}
|
||||
func loadDispatchRewardGroup(db *sql.DB, gid uint64, seen map[uint64]bool) (*dispatchGroup, error) {
|
||||
var raw []byte
|
||||
err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", gid).Scan(&raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := &dispatchGroup{}
|
||||
g.Drop, _ = optionalScalar(raw, 2)
|
||||
g.Count, _ = optionalScalar(raw, 1)
|
||||
fields := map[int][]uint64{}
|
||||
for _, n := range []int{4, 5, 6, 8} {
|
||||
fields[n], err = packedInts(raw, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(fields[5]) == 0 || len(fields[4]) != len(fields[5]) || len(fields[6]) != len(fields[5]) || len(fields[8]) != len(fields[5]) || g.Drop > 1 || (g.Drop == 0 && g.Count == 0) {
|
||||
return nil, fmt.Errorf("gamedata: malformed dispatch reward group %d", gid)
|
||||
}
|
||||
for i, item := range fields[5] {
|
||||
e := dispatchEntry{Reward: BattleReward{fields[6][i], item, fields[4][i]}, Weight: fields[8][i]}
|
||||
if e.Reward.Count == 0 {
|
||||
return nil, fmt.Errorf("gamedata: empty dispatch reward")
|
||||
}
|
||||
if e.Reward.Type == 9 {
|
||||
e.Child, err = loadDispatchBox(db, item, seen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
g.Entries = append(g.Entries, e)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// Roll uses the local server's random source; GameData supplies every weight and quantity.
|
||||
func (d *DispatchDesign) Roll(count uint64, draw func(uint64) (uint64, error)) ([]BattleReward, error) {
|
||||
if d == nil || count == 0 || count > 20 {
|
||||
return nil, fmt.Errorf("gamedata: invalid dispatch count")
|
||||
}
|
||||
var budget uint64 = 100000
|
||||
var out []BattleReward
|
||||
var run func(*dispatchGroup) error
|
||||
emit := func(e dispatchEntry) error {
|
||||
if e.Child != nil {
|
||||
for i := uint64(0); i < e.Reward.Count; i++ {
|
||||
if err := run(e.Child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if len(out) >= 100000 {
|
||||
return fmt.Errorf("gamedata: dispatch reward count exceeds operation limit")
|
||||
}
|
||||
out = append(out, e.Reward)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
run = func(g *dispatchGroup) error {
|
||||
if g == nil || budget == 0 {
|
||||
return fmt.Errorf("gamedata: dispatch reward program exceeds operation limit")
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var sum uint64
|
||||
for _, e := range g.Entries {
|
||||
if e.Weight == 0 || math.MaxUint64-sum < e.Weight {
|
||||
return fmt.Errorf("gamedata: invalid dispatch weight")
|
||||
}
|
||||
sum += e.Weight
|
||||
}
|
||||
for i := uint64(0); i < g.Count; i++ {
|
||||
v, err := draw(sum)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v >= sum {
|
||||
return fmt.Errorf("gamedata: random out of range")
|
||||
}
|
||||
for _, e := range g.Entries {
|
||||
if v < e.Weight {
|
||||
if err := emit(e); err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
v -= e.Weight
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for range count {
|
||||
for _, r := range d.Rewards {
|
||||
if r.Type == 9 {
|
||||
for n := uint64(0); n < r.Count; n++ {
|
||||
if err := run(d.boxes[r.ID]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
aggregate := map[[2]uint64]uint64{}
|
||||
for _, r := range out {
|
||||
k := [2]uint64{r.Type, r.ID}
|
||||
if r.Count > math.MaxInt32-aggregate[k] {
|
||||
return nil, fmt.Errorf("gamedata: dispatch reward overflow")
|
||||
}
|
||||
aggregate[k] += r.Count
|
||||
}
|
||||
out = nil
|
||||
for k, n := range aggregate {
|
||||
out = append(out, BattleReward{k[0], k[1], n})
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
return c.includeCollaborationURWeapons(db)
|
||||
}
|
||||
|
||||
func (c *EquipmentGachaCatalog) includeCollaborationURWeapons(db *sql.DB) error {
|
||||
if c == nil || c.equipment == nil {
|
||||
return fmt.Errorf("gamedata: missing equipment catalog")
|
||||
}
|
||||
// Select guaranteed weapon products from their existing equipment definitions,
|
||||
// rather than tying eligibility to a product or resource-ticket number.
|
||||
var targets []uint64
|
||||
for id, g := range c.Gachas {
|
||||
if !g.TicketOnly || len(g.TicketIDs) == 0 || len(g.Pool) == 0 {
|
||||
continue
|
||||
}
|
||||
eligible := true
|
||||
for _, branch := range g.Pool {
|
||||
if branch.ID != 0 || branch.Weight == 0 || len(branch.Children) == 0 {
|
||||
eligible = false
|
||||
break
|
||||
}
|
||||
for _, item := range branch.Children {
|
||||
if item.ID == 0 || len(item.Children) != 0 {
|
||||
eligible = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !eligible {
|
||||
continue
|
||||
}
|
||||
var visit func([]WeightedEquipment) error
|
||||
visit = func(pool []WeightedEquipment) error {
|
||||
for _, entry := range pool {
|
||||
if entry.ID == 0 {
|
||||
if len(entry.Children) == 0 {
|
||||
eligible = false
|
||||
}
|
||||
if err := visit(entry.Children); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM EquipmentTable WHERE id=?", entry.ID).Scan(&raw); err != nil {
|
||||
return err
|
||||
}
|
||||
grade, e1 := packedInts(raw, 3)
|
||||
quality, e2 := packedInts(raw, 18)
|
||||
owner, e3 := packedInts(raw, 16)
|
||||
if e1 != nil || e2 != nil || e3 != nil {
|
||||
return fmt.Errorf("gamedata: malformed ticket equipment %d", entry.ID)
|
||||
}
|
||||
if len(grade) != 1 || grade[0] != 4 || len(quality) != 1 || quality[0] != 3 || len(owner) != 1 || owner[0] == 0 {
|
||||
eligible = false
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := visit(g.Pool); err != nil {
|
||||
return err
|
||||
}
|
||||
if eligible {
|
||||
targets = append(targets, id)
|
||||
}
|
||||
}
|
||||
slices.Sort(targets)
|
||||
// Publish only after all selected products validate successfully.
|
||||
clone := *c
|
||||
clone.Gachas = make(map[uint64]EquipmentGacha, len(c.Gachas))
|
||||
maps.Copy(clone.Gachas, c.Gachas)
|
||||
clone.equipment = make(map[uint64]EquipmentDesign, len(c.equipment))
|
||||
maps.Copy(clone.equipment, c.equipment)
|
||||
for _, id := range targets {
|
||||
if err := clone.includeCollaborationWeaponPool(db, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c.Gachas, c.equipment = clone.Gachas, clone.equipment
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *EquipmentGachaCatalog) includeCollaborationWeaponPool(db *sql.DB, id uint64) error {
|
||||
g := c.Gachas[id]
|
||||
readRows := func(query string) (map[uint64][]byte, error) {
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := map[uint64][]byte{}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = raw
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
scalar := func(raw []byte, field int) (uint64, error) {
|
||||
v, err := packedInts(raw, field)
|
||||
if err != nil || len(v) != 1 || v[0] == 0 {
|
||||
return 0, fmt.Errorf("gamedata: invalid collaboration equipment field %d", field)
|
||||
}
|
||||
return v[0], nil
|
||||
}
|
||||
owners, err := readRows("SELECT DISTINCT c.useUniqueCharId,c.ProtoBuf FROM LimitedCostumeTable l JOIN CostumeTable c ON c.id=l.id ORDER BY c.useUniqueCharId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
chars, err := readRows("SELECT id,ProtoBuf FROM CharTable WHERE uniqueCharId IN (SELECT privateUniqueCharId FROM EquipmentTable WHERE privateUniqueCharId>0) ORDER BY id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grades := map[uint64]uint64{}
|
||||
for _, raw := range chars {
|
||||
owner, err := scalar(raw, 20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grade, err := scalar(raw, 9)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if previous, exists := grades[owner]; exists && previous != grade {
|
||||
return fmt.Errorf("gamedata: conflicting character grade for %d", owner)
|
||||
}
|
||||
grades[owner] = grade
|
||||
}
|
||||
equipment, err := readRows("SELECT id,ProtoBuf FROM EquipmentTable ORDER BY id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pool := make([]WeightedEquipment, len(g.Pool))
|
||||
branchForGrade := map[uint64]int{}
|
||||
seen := map[uint64]bool{}
|
||||
for index, branch := range g.Pool {
|
||||
if branch.ID != 0 || branch.Weight == 0 || len(branch.Children) == 0 {
|
||||
return fmt.Errorf("gamedata: malformed UR ticket branch")
|
||||
}
|
||||
pool[index] = WeightedEquipment{Weight: branch.Weight, Children: append([]WeightedEquipment(nil), branch.Children...)}
|
||||
var branchGrade uint64
|
||||
for _, item := range branch.Children {
|
||||
if item.ID == 0 || item.Weight != 1 || len(item.Children) != 0 || seen[item.ID] {
|
||||
return fmt.Errorf("gamedata: malformed or duplicate UR ticket candidate %d", item.ID)
|
||||
}
|
||||
seen[item.ID] = true
|
||||
owner, err := scalar(equipment[item.ID], 16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grade := grades[owner]
|
||||
if grade == 0 || (branchGrade != 0 && branchGrade != grade) {
|
||||
return fmt.Errorf("gamedata: unknown or mixed UR ticket character tier")
|
||||
}
|
||||
branchGrade = grade
|
||||
}
|
||||
if _, exists := branchForGrade[branchGrade]; exists {
|
||||
return fmt.Errorf("gamedata: duplicate UR ticket tier")
|
||||
}
|
||||
branchForGrade[branchGrade] = index
|
||||
}
|
||||
// Collect designs before publishing the cloned pool, so a failed load leaves
|
||||
// the catalog unchanged. Order by ID to keep repeated loads deterministic.
|
||||
designs := map[uint64]EquipmentDesign{}
|
||||
ids := make([]uint64, 0, len(equipment))
|
||||
for id := range equipment {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
slices.Sort(ids)
|
||||
for _, id := range ids {
|
||||
raw := equipment[id]
|
||||
grade, _ := packedInts(raw, 3)
|
||||
quality, _ := packedInts(raw, 18)
|
||||
owner, _ := packedInts(raw, 16)
|
||||
if len(grade) != 1 || grade[0] != 4 || len(quality) != 1 || quality[0] != 3 || len(owner) != 1 {
|
||||
continue
|
||||
}
|
||||
if _, eligible := owners[owner[0]]; !eligible || seen[id] {
|
||||
continue
|
||||
}
|
||||
index, exists := branchForGrade[grades[owner[0]]]
|
||||
if !exists {
|
||||
return fmt.Errorf("gamedata: unknown collaboration equipment tier for %d", id)
|
||||
}
|
||||
design, err := loadEquipmentDesign(db, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
designs[id] = design
|
||||
pool[index].Children = append(pool[index].Children, WeightedEquipment{ID: id, Weight: 1})
|
||||
seen[id] = true
|
||||
}
|
||||
maps.Copy(c.equipment, designs)
|
||||
g.Pool = pool
|
||||
g.Grades = make(map[uint64]uint64, len(seen))
|
||||
for id := range seen {
|
||||
g.Grades[id] = c.equipment[id].Grade
|
||||
}
|
||||
c.Gachas[g.ID] = g
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
const equipmentMakingTalentClass = 9
|
||||
|
||||
type EquipmentCraftRecipe struct {
|
||||
ID uint64
|
||||
ResultCount uint64
|
||||
TalentLevel uint64
|
||||
Costs []PromotionCost
|
||||
Results []WeightedEquipment
|
||||
}
|
||||
|
||||
type equipmentMakingTalent struct {
|
||||
SkillGroup uint64
|
||||
GrowthGroup uint64
|
||||
}
|
||||
|
||||
type equipmentMakingTalentSkill struct {
|
||||
Experience uint64
|
||||
Catalyst uint64
|
||||
}
|
||||
|
||||
type GeneratedEquipment struct {
|
||||
Design EquipmentDesign
|
||||
Main []EquipmentOptionChoice
|
||||
Sub []EquipmentOptionChoice
|
||||
Private *EquipmentOptionChoice
|
||||
}
|
||||
|
||||
type EquipmentCraftDesign struct {
|
||||
Recipes map[uint64]EquipmentCraftRecipe
|
||||
Equipment map[uint64]EquipmentDesign
|
||||
CharTalent map[uint64]equipmentMakingTalent
|
||||
Skills map[[2]uint64]equipmentMakingTalentSkill
|
||||
TalentNeed map[[2]uint64]uint64
|
||||
draw func(uint64) (uint64, error)
|
||||
}
|
||||
|
||||
func LoadEquipmentCraftDesign(root, version string) (*EquipmentCraftDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
return loadEquipmentCraftDesign(db)
|
||||
}
|
||||
|
||||
func loadEquipmentCraftDesign(db *sql.DB) (*EquipmentCraftDesign, error) {
|
||||
design := &EquipmentCraftDesign{
|
||||
Recipes: make(map[uint64]EquipmentCraftRecipe), Equipment: make(map[uint64]EquipmentDesign),
|
||||
CharTalent: make(map[uint64]equipmentMakingTalent), Skills: make(map[[2]uint64]equipmentMakingTalentSkill),
|
||||
TalentNeed: make(map[[2]uint64]uint64), draw: cryptoDraw,
|
||||
}
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM EquipmentMakingTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type makingRow struct {
|
||||
id uint64
|
||||
raw []byte
|
||||
}
|
||||
var makingRows []makingRow
|
||||
for rows.Next() {
|
||||
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)
|
||||
resultCounts, _ := packedInts(raw, 7)
|
||||
boxIDs, _ := packedInts(raw, 8)
|
||||
resultTypes, _ := packedInts(raw, 9)
|
||||
talentLevels, _ := packedInts(raw, 11)
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
return nil, fmt.Errorf("gamedata: equipment making recipe %d: %w", id, err)
|
||||
}
|
||||
design.Recipes[id] = recipe
|
||||
}
|
||||
|
||||
talents := make(map[uint64]equipmentMakingTalent)
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM TalentTable")
|
||||
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
|
||||
}
|
||||
classes, _ := packedInts(raw, 4)
|
||||
growthGroups, _ := packedInts(raw, 6)
|
||||
skillGroups, _ := packedInts(raw, 18)
|
||||
if len(classes) == 1 && classes[0] == equipmentMakingTalentClass && len(growthGroups) == 1 && len(skillGroups) == 1 {
|
||||
talents[id] = equipmentMakingTalent{SkillGroup: skillGroups[0], GrowthGroup: growthGroups[0]}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable")
|
||||
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
|
||||
}
|
||||
talentIDs, _ := packedInts(raw, 18)
|
||||
if len(talentIDs) == 1 {
|
||||
if talent, ok := talents[talentIDs[0]]; ok {
|
||||
design.CharTalent[id] = talent
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM TalentSkillTable WHERE classType=?", equipmentMakingTalentClass)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var group, level uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&group, &level, &raw); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
catalysts, _ := packedInts(raw, 1)
|
||||
experience, _ := packedInts(raw, 5)
|
||||
if len(catalysts) > 1 || len(experience) > 1 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment making talent skill %d/%d malformed", group, level)
|
||||
}
|
||||
var skill equipmentMakingTalentSkill
|
||||
if len(experience) == 1 {
|
||||
skill.Experience = experience[0]
|
||||
}
|
||||
if len(catalysts) == 1 {
|
||||
skill.Catalyst = catalysts[0]
|
||||
}
|
||||
design.Skills[[2]uint64{group, level}] = skill
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM TalentGrowthTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var group, level uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&group, &level, &raw); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
need, err := packedInts(raw, 6)
|
||||
if err != nil || len(need) > 1 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: malformed equipment talent growth %d/%d", group, level)
|
||||
}
|
||||
design.TalentNeed[[2]uint64{group, level}] = 0
|
||||
if len(need) == 1 {
|
||||
design.TalentNeed[[2]uint64{group, level}] = need[0]
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return design, nil
|
||||
}
|
||||
|
||||
func (d *EquipmentCraftDesign) loadEquipmentPool(db *sql.DB, pool []WeightedEquipment) error {
|
||||
for _, result := range pool {
|
||||
if result.ID == 0 {
|
||||
if len(result.Children) == 0 {
|
||||
return fmt.Errorf("empty nested equipment reward")
|
||||
}
|
||||
if err := d.loadEquipmentPool(db, result.Children); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(result.Children) != 0 {
|
||||
return fmt.Errorf("equipment reward %d also has children", result.ID)
|
||||
}
|
||||
if _, exists := d.Equipment[result.ID]; exists {
|
||||
continue
|
||||
}
|
||||
equipment, err := loadEquipmentDesign(db, result.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.Equipment[result.ID] = equipment
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *EquipmentCraftDesign) Recipe(id uint64) (EquipmentCraftRecipe, bool) {
|
||||
if d == nil {
|
||||
return EquipmentCraftRecipe{}, false
|
||||
}
|
||||
recipe, ok := d.Recipes[id]
|
||||
return recipe, ok
|
||||
}
|
||||
|
||||
func (d *EquipmentCraftDesign) Generate(recipeID uint64) (GeneratedEquipment, error) {
|
||||
if d == nil {
|
||||
return GeneratedEquipment{}, fmt.Errorf("gamedata: equipment crafting unavailable")
|
||||
}
|
||||
recipe, ok := d.Recipes[recipeID]
|
||||
if !ok {
|
||||
return GeneratedEquipment{}, fmt.Errorf("gamedata: unknown equipment making recipe %d", recipeID)
|
||||
}
|
||||
draw := d.draw
|
||||
if draw == nil {
|
||||
draw = cryptoDraw
|
||||
}
|
||||
id, err := rollEquipmentChoiceWith(recipe.Results, draw)
|
||||
if err != nil {
|
||||
return GeneratedEquipment{}, err
|
||||
}
|
||||
equipment, ok := d.Equipment[id]
|
||||
if !ok {
|
||||
return GeneratedEquipment{}, fmt.Errorf("gamedata: equipment making result %d missing", id)
|
||||
}
|
||||
main, sub, private, err := rollEquipmentOptionsWith(equipment, draw)
|
||||
if err != nil {
|
||||
return GeneratedEquipment{}, err
|
||||
}
|
||||
return GeneratedEquipment{Design: equipment, Main: main, Sub: sub, Private: private}, nil
|
||||
}
|
||||
|
||||
func rollEquipmentOptionsWith(design EquipmentDesign, draw func(uint64) (uint64, error)) (main, sub []EquipmentOptionChoice, private *EquipmentOptionChoice, err error) {
|
||||
pick := func(groups []OptionGroup) ([]EquipmentOptionChoice, error) {
|
||||
out := make([]EquipmentOptionChoice, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
pool := make([]WeightedEquipment, 0, len(group.Choices))
|
||||
for _, choice := range group.Choices {
|
||||
pool = append(pool, WeightedEquipment{ID: choice.ID, Weight: choice.Weight})
|
||||
}
|
||||
id, err := rollEquipmentChoiceWith(pool, draw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, EquipmentOptionChoice{GroupID: group.ID, ID: id})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if main, err = pick(design.Main); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if sub, err = pick(design.Sub); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
choices, err := pick(design.Private)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if len(choices) > 1 {
|
||||
return nil, nil, nil, fmt.Errorf("gamedata: equipment %d has %d private option groups", design.ID, len(choices))
|
||||
}
|
||||
if len(choices) == 1 {
|
||||
choice := choices[0]
|
||||
private = &choice
|
||||
}
|
||||
return main, sub, private, nil
|
||||
}
|
||||
|
||||
func (d *EquipmentCraftDesign) Talent(characterID, characterLevel, recipeLevel, count, currentExperience uint64) (gain, catalyst, maximum uint64, err error) {
|
||||
if d == nil || characterID == 0 || characterLevel == 0 || recipeLevel == 0 || count == 0 {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: invalid equipment making talent request")
|
||||
}
|
||||
talent, ok := d.CharTalent[characterID]
|
||||
if !ok {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: character %d has no equipment making talent", characterID)
|
||||
}
|
||||
if characterLevel < recipeLevel {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: equipment making talent level %d is below recipe level %d", characterLevel, recipeLevel)
|
||||
}
|
||||
recipeSkill, ok := d.Skills[[2]uint64{talent.SkillGroup, recipeLevel}]
|
||||
if !ok {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: equipment making talent skill %d/%d missing", talent.SkillGroup, recipeLevel)
|
||||
}
|
||||
currentSkill, ok := d.Skills[[2]uint64{talent.SkillGroup, characterLevel}]
|
||||
if !ok {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: equipment making talent skill %d/%d missing", talent.SkillGroup, characterLevel)
|
||||
}
|
||||
if currentSkill.Experience != 0 && count > math.MaxUint64/currentSkill.Experience {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: equipment making talent experience overflow")
|
||||
}
|
||||
if recipeSkill.Catalyst != 0 && count > math.MaxUint64/recipeSkill.Catalyst {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: equipment making catalyst overflow")
|
||||
}
|
||||
for level := uint64(1); level <= characterLevel; level++ {
|
||||
need, exists := d.TalentNeed[[2]uint64{talent.GrowthGroup, level}]
|
||||
if !exists {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: equipment making talent growth %d/%d missing", talent.GrowthGroup, level)
|
||||
}
|
||||
if maximum > math.MaxUint64-need {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: equipment making talent threshold overflow")
|
||||
}
|
||||
maximum += need
|
||||
}
|
||||
wanted := currentSkill.Experience * count
|
||||
if currentExperience >= maximum {
|
||||
wanted = 0
|
||||
} else if wanted > maximum-currentExperience {
|
||||
wanted = maximum - currentExperience
|
||||
}
|
||||
return wanted, recipeSkill.Catalyst * count, maximum, nil
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/big"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// EquipmentGachaCatalog is the server-owned portion of exclusive-equipment
|
||||
// draws. Scheduled groups and standalone ticket-only draws are both derived
|
||||
// from GameData; the server does not enumerate product IDs in request logic.
|
||||
type EquipmentGachaCatalog struct {
|
||||
Gachas map[uint64]EquipmentGacha
|
||||
groups map[uint64]EquipmentGachaGroup
|
||||
byGacha map[uint64]uint64
|
||||
fixed EquipmentFixedDesign
|
||||
fixedDesigns map[uint64]EquipmentFixedDesign
|
||||
equipment map[uint64]EquipmentDesign
|
||||
}
|
||||
type EquipmentGacha struct {
|
||||
ID uint64
|
||||
Count int
|
||||
FreeCountDay uint64
|
||||
PriceType, Price uint64
|
||||
TicketIDs []uint64
|
||||
Pool []WeightedEquipment
|
||||
TicketOnly bool
|
||||
Grades map[uint64]uint64
|
||||
}
|
||||
type EquipmentGachaGroup struct {
|
||||
ID, FixedID, PointCount, OneTimeGachaID, TenTimeGachaID uint64
|
||||
PickUpExchangeCost, PickUpItemID uint64
|
||||
}
|
||||
type EquipmentFixedDesign struct {
|
||||
ID, SRCount, URCount uint64
|
||||
Reset bool
|
||||
}
|
||||
type WeightedEquipment struct {
|
||||
ID, Weight uint64
|
||||
Children []WeightedEquipment
|
||||
}
|
||||
type EquipmentDesign struct {
|
||||
ID uint64
|
||||
Grade uint64
|
||||
Main, Sub []OptionGroup
|
||||
Private []OptionGroup
|
||||
RankGroupID uint64
|
||||
}
|
||||
type OptionGroup struct {
|
||||
ID uint64
|
||||
Choices []WeightedOption
|
||||
}
|
||||
type WeightedOption struct{ ID, Weight uint64 }
|
||||
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) {
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM GachaGroupTable WHERE id=?", groupID).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
one, _ := packedInts(raw, 24)
|
||||
ten, _ := packedInts(raw, 33)
|
||||
fixed, _ := packedInts(raw, 10)
|
||||
points, _ := packedInts(raw, 27)
|
||||
gachaTypes, _ := packedInts(raw, 17)
|
||||
pickupEnabled, _ := packedInts(raw, 20)
|
||||
pickupCosts, _ := packedInts(raw, 25)
|
||||
pickupItems, _ := packedInts(raw, 26)
|
||||
if len(one) != 1 || len(ten) != 1 || len(fixed) != 1 || fixed[0] == 0 || len(points) != 1 || points[0] == 0 ||
|
||||
len(gachaTypes) != 1 || gachaTypes[0] != 2 || len(pickupEnabled) > 1 || len(pickupCosts) > 1 || len(pickupItems) > 1 {
|
||||
return nil, fmt.Errorf("gamedata: equipment group %d malformed", groupID)
|
||||
}
|
||||
pickupCost, pickupItem := uint64(0), uint64(0)
|
||||
if len(pickupEnabled) == 1 && pickupEnabled[0] == 1 {
|
||||
if len(pickupCosts) != 1 || pickupCosts[0] == 0 || len(pickupItems) != 1 || pickupItems[0] == 0 {
|
||||
return nil, fmt.Errorf("gamedata: equipment group %d has malformed pickup exchange", groupID)
|
||||
}
|
||||
pickupCost, pickupItem = pickupCosts[0], pickupItems[0]
|
||||
} else if len(pickupEnabled) != 0 || len(pickupCosts) != 0 || len(pickupItems) != 0 {
|
||||
return nil, fmt.Errorf("gamedata: equipment group %d has inconsistent pickup exchange", groupID)
|
||||
}
|
||||
// The absence of SelectCount/GachaSubType is deliberate evidence that
|
||||
// this live group is not an equipment 12PICK configuration.
|
||||
for _, field := range []int{16, 29, 31, 36} {
|
||||
if v, _ := packedInts(raw, field); len(v) != 0 {
|
||||
return nil, fmt.Errorf("gamedata: equipment group %d unexpectedly has selection field %d=%v", groupID, field, v)
|
||||
}
|
||||
}
|
||||
group := EquipmentGachaGroup{
|
||||
ID: groupID, FixedID: fixed[0], PointCount: points[0], OneTimeGachaID: one[0], TenTimeGachaID: ten[0],
|
||||
PickUpExchangeCost: pickupCost, PickUpItemID: pickupItem,
|
||||
}
|
||||
c.groups[groupID] = group
|
||||
for _, id := range []uint64{one[0], ten[0]} {
|
||||
g, err := loadEquipmentGacha(db, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Gachas[id] = g
|
||||
c.byGacha[id] = groupID
|
||||
for _, item := range g.Pool {
|
||||
if err := c.loadEquipmentTree(db, item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if group.PickUpItemID != 0 {
|
||||
if err := c.loadEquipmentTree(db, WeightedEquipment{ID: group.PickUpItemID}); err != nil {
|
||||
return nil, fmt.Errorf("gamedata: equipment group %d pickup: %w", groupID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Guaranteed equipment tickets are standalone GachaTable rows: they have
|
||||
// no diamond price, at least one resource-ticket id, and an equipment-only
|
||||
// RewardGroup tree. Discover every such row instead of special-casing a
|
||||
// product number from a log.
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM GachaTable ORDER BY id")
|
||||
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
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, exists := c.Gachas[id]; exists {
|
||||
continue
|
||||
}
|
||||
count, _ := packedInts(raw, 5)
|
||||
reward, _ := packedInts(raw, 7)
|
||||
price, _ := packedInts(raw, 10)
|
||||
kind, _ := packedInts(raw, 12)
|
||||
tickets, _ := packedInts(raw, 8)
|
||||
if len(count) != 1 || count[0] == 0 || len(reward) != 1 || len(tickets) == 0 || len(price) != 0 || len(kind) != 0 {
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
return nil, fmt.Errorf("gamedata: ticket gacha %d: %w", id, err)
|
||||
}
|
||||
if !equipmentOnly {
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
for id, g := range c.Gachas {
|
||||
g.Grades = map[uint64]uint64{}
|
||||
var visit func([]WeightedEquipment)
|
||||
visit = func(pool []WeightedEquipment) {
|
||||
for _, entry := range pool {
|
||||
if entry.ID != 0 {
|
||||
g.Grades[entry.ID] = c.equipment[entry.ID].Grade
|
||||
} else {
|
||||
visit(entry.Children)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(g.Pool)
|
||||
c.Gachas[id] = g
|
||||
}
|
||||
c.fixedDesigns = map[uint64]EquipmentFixedDesign{}
|
||||
for _, group := range c.groups {
|
||||
if _, ok := c.fixedDesigns[group.FixedID]; ok {
|
||||
continue
|
||||
}
|
||||
fixed, err := loadEquipmentFixed(db, group.FixedID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.fixedDesigns[fixed.ID] = fixed
|
||||
if c.fixed.ID == 0 || fixed.ID < c.fixed.ID {
|
||||
c.fixed = fixed
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func loadEquipmentFixed(db *sql.DB, id uint64) (EquipmentFixedDesign, error) {
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM GachaFixedTable WHERE id=?", id).Scan(&raw); err != nil {
|
||||
return EquipmentFixedDesign{}, err
|
||||
}
|
||||
sr, e1 := packedInts(raw, 1)
|
||||
ur, e2 := packedInts(raw, 3)
|
||||
reset, e3 := packedInts(raw, 6)
|
||||
if e1 != nil || e2 != nil || e3 != nil || len(sr) != 1 || sr[0] == 0 || len(ur) != 1 || ur[0] == 0 || len(reset) > 1 || (len(reset) == 1 && reset[0] > 1) {
|
||||
return EquipmentFixedDesign{}, fmt.Errorf("gamedata: malformed equipment fixed table %d", id)
|
||||
}
|
||||
return EquipmentFixedDesign{ID: id, SRCount: sr[0], URCount: ur[0], Reset: len(reset) == 1 && reset[0] == 1}, nil
|
||||
}
|
||||
|
||||
func classifyEquipmentRewardPool(db *sql.DB, groupID uint64) ([]WeightedEquipment, bool, error) {
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", groupID).Scan(&raw); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
ids, _ := packedInts(raw, 5)
|
||||
types, _ := packedInts(raw, 6)
|
||||
weights, _ := packedInts(raw, 8)
|
||||
if len(ids) == 0 || len(ids) != len(types) || len(ids) != len(weights) {
|
||||
return nil, false, errors.New("malformed reward group")
|
||||
}
|
||||
out := make([]WeightedEquipment, 0, len(ids))
|
||||
for i, id := range ids {
|
||||
entry := WeightedEquipment{Weight: weights[i]}
|
||||
if entry.Weight == 0 {
|
||||
return nil, false, errors.New("reward group has zero weight")
|
||||
}
|
||||
switch types[i] {
|
||||
case 10:
|
||||
entry.ID = id
|
||||
case 9:
|
||||
children, equipmentOnly, err := classifyEquipmentRewardPool(db, id)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !equipmentOnly {
|
||||
return nil, false, nil
|
||||
}
|
||||
entry.Children = children
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, true, nil
|
||||
}
|
||||
|
||||
func loadEquipmentGacha(db *sql.DB, id uint64) (EquipmentGacha, error) {
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM GachaTable WHERE id=?", id).Scan(&raw); err != nil {
|
||||
return EquipmentGacha{}, err
|
||||
}
|
||||
count, _ := packedInts(raw, 5)
|
||||
reward, _ := packedInts(raw, 7)
|
||||
price, _ := packedInts(raw, 10)
|
||||
kind, _ := packedInts(raw, 12)
|
||||
tickets, _ := packedInts(raw, 8)
|
||||
freeCounts, err := packedInts(raw, 4)
|
||||
if err != nil || len(freeCounts) > 1 {
|
||||
return EquipmentGacha{}, fmt.Errorf("gamedata: malformed equipment free allowance %d", id)
|
||||
}
|
||||
var freeCount uint64
|
||||
if len(freeCounts) == 1 {
|
||||
freeCount = freeCounts[0]
|
||||
}
|
||||
// Partial ticket consumption bills remaining draws individually; the local
|
||||
// billing path currently requires an integral price per draw.
|
||||
if len(count) != 1 || len(reward) != 1 || len(price) != 1 || len(kind) != 1 || kind[0] != 3 || count[0] == 0 || price[0] == 0 || price[0]%count[0] != 0 {
|
||||
return EquipmentGacha{}, fmt.Errorf("gamedata: malformed equipment gacha %d", id)
|
||||
}
|
||||
pool, err := loadEquipmentRewardPool(db, reward[0])
|
||||
if err != nil {
|
||||
return EquipmentGacha{}, fmt.Errorf("gamedata: equipment gacha %d: %w", id, err)
|
||||
}
|
||||
return EquipmentGacha{ID: id, Count: int(count[0]), FreeCountDay: freeCount, PriceType: kind[0], Price: price[0], TicketIDs: append([]uint64(nil), tickets...), Pool: pool}, nil
|
||||
}
|
||||
func loadEquipmentRewardPool(db *sql.DB, groupID uint64) ([]WeightedEquipment, error) {
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", groupID).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids, _ := packedInts(raw, 5)
|
||||
types, _ := packedInts(raw, 6)
|
||||
weights, _ := packedInts(raw, 8)
|
||||
if len(ids) == 0 || len(ids) != len(types) || len(ids) != len(weights) {
|
||||
return nil, errors.New("malformed equipment reward group")
|
||||
}
|
||||
out := make([]WeightedEquipment, 0, len(ids))
|
||||
for i, id := range ids {
|
||||
v := WeightedEquipment{Weight: weights[i]}
|
||||
switch types[i] {
|
||||
case 10:
|
||||
v.ID = id
|
||||
case 9:
|
||||
children, err := loadEquipmentRewardPool(db, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.Children = children
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported equipment reward type %d", types[i])
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (c *EquipmentGachaCatalog) loadEquipmentTree(db *sql.DB, item WeightedEquipment) error {
|
||||
if item.ID == 0 {
|
||||
for _, child := range item.Children {
|
||||
if err := c.loadEquipmentTree(db, child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, ok := c.equipment[item.ID]; ok {
|
||||
return nil
|
||||
}
|
||||
d, err := loadEquipmentDesign(db, item.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.equipment[item.ID] = d
|
||||
return nil
|
||||
}
|
||||
func loadEquipmentDesign(db *sql.DB, id uint64) (EquipmentDesign, error) {
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM EquipmentTable WHERE id=?", id).Scan(&raw); err != nil {
|
||||
return EquipmentDesign{}, err
|
||||
}
|
||||
main, _ := packedInts(raw, 12)
|
||||
private, _ := packedInts(raw, 17)
|
||||
sub, _ := packedInts(raw, 21)
|
||||
rank, _ := packedInts(raw, 19)
|
||||
grade, _ := packedInts(raw, 3)
|
||||
if len(grade) != 1 || grade[0] == 0 {
|
||||
return EquipmentDesign{}, fmt.Errorf("gamedata: equipment %d malformed grade", id)
|
||||
}
|
||||
d := EquipmentDesign{ID: id, Grade: grade[0]}
|
||||
var err error
|
||||
if d.Main, err = loadOptionGroups(db, main); err != nil {
|
||||
return d, err
|
||||
}
|
||||
if d.Sub, err = loadOptionGroups(db, sub); err != nil {
|
||||
return d, err
|
||||
}
|
||||
if d.Private, err = loadOptionGroups(db, private); err != nil {
|
||||
return d, err
|
||||
}
|
||||
if len(rank) == 1 {
|
||||
d.RankGroupID = rank[0]
|
||||
} else if len(rank) != 0 {
|
||||
return d, fmt.Errorf("gamedata: equipment %d malformed rank group", id)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
func loadOptionGroups(db *sql.DB, ids []uint64) ([]OptionGroup, error) {
|
||||
out := make([]OptionGroup, 0, len(ids))
|
||||
for _, groupID := range ids {
|
||||
rows, err := db.Query("SELECT ProtoBuf FROM EquipmentOptionTable WHERE GroupId=? ORDER BY id", groupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := OptionGroup{ID: groupID}
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
id, _ := packedInts(raw, 5)
|
||||
weight, _ := packedInts(raw, 2)
|
||||
if len(id) != 1 || len(weight) != 1 || weight[0] == 0 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: malformed equipment option group %d", groupID)
|
||||
}
|
||||
g.Choices = append(g.Choices, WeightedOption{ID: id[0], Weight: weight[0]})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(g.Choices) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: equipment option group %d empty", groupID)
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (c *EquipmentGachaCatalog) Gacha(id uint64) (EquipmentGacha, bool) {
|
||||
g, ok := c.Gachas[id]
|
||||
return g, ok
|
||||
}
|
||||
func (c *EquipmentGachaCatalog) GroupForGacha(id uint64) (EquipmentGachaGroup, bool) {
|
||||
group, ok := c.groups[c.byGacha[id]]
|
||||
return group, ok
|
||||
}
|
||||
func (c *EquipmentGachaCatalog) Group(id uint64) (EquipmentGachaGroup, bool) {
|
||||
if c == nil {
|
||||
return EquipmentGachaGroup{}, false
|
||||
}
|
||||
group, ok := c.groups[id]
|
||||
return group, ok
|
||||
}
|
||||
func (c *EquipmentGachaCatalog) Groups() []EquipmentGachaGroup {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]EquipmentGachaGroup, 0, len(c.groups))
|
||||
for _, group := range c.groups {
|
||||
out = append(out, group)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
func (c *EquipmentGachaCatalog) Fixed() EquipmentFixedDesign { return c.fixed }
|
||||
func (c *EquipmentGachaCatalog) FixedForGroup(groupID uint64) (EquipmentFixedDesign, bool) {
|
||||
if c == nil {
|
||||
return EquipmentFixedDesign{}, false
|
||||
}
|
||||
group, ok := c.groups[groupID]
|
||||
if !ok {
|
||||
return EquipmentFixedDesign{}, false
|
||||
}
|
||||
fixed, ok := c.fixedDesigns[group.FixedID]
|
||||
return fixed, ok
|
||||
}
|
||||
func (c *EquipmentGachaCatalog) FixedDesigns() []EquipmentFixedDesign {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]EquipmentFixedDesign, 0, len(c.fixedDesigns))
|
||||
for _, fixed := range c.fixedDesigns {
|
||||
out = append(out, fixed)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
func (c *EquipmentGachaCatalog) RollOptions(id uint64) (main, sub []EquipmentOptionChoice, private *EquipmentOptionChoice, err error) {
|
||||
d, ok := c.equipment[id]
|
||||
if !ok {
|
||||
return nil, nil, nil, fmt.Errorf("gamedata: equipment %d absent from active gacha", id)
|
||||
}
|
||||
pick := func(groups []OptionGroup) ([]EquipmentOptionChoice, error) {
|
||||
out := make([]EquipmentOptionChoice, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
options := make([]WeightedEquipment, 0, len(g.Choices))
|
||||
for _, choice := range g.Choices {
|
||||
options = append(options, WeightedEquipment{ID: choice.ID, Weight: choice.Weight})
|
||||
}
|
||||
picked, e := rollEquipmentChoiceWith(options, cryptoDraw)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
out = append(out, EquipmentOptionChoice{GroupID: g.ID, ID: picked})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if main, err = pick(d.Main); err != nil {
|
||||
return
|
||||
}
|
||||
if sub, err = pick(d.Sub); err != nil {
|
||||
return
|
||||
}
|
||||
choices, err := pick(d.Private)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if len(choices) > 1 {
|
||||
return nil, nil, nil, fmt.Errorf("gamedata: equipment %d has %d private option groups", id, len(choices))
|
||||
}
|
||||
if len(choices) == 1 {
|
||||
private = &choices[0]
|
||||
}
|
||||
return
|
||||
}
|
||||
func (g EquipmentGacha) Roll(previousSR, previousUR uint64, fixed EquipmentFixedDesign) ([]uint64, EquipmentRoll, error) {
|
||||
if g.TicketOnly {
|
||||
state := EquipmentRoll{SRCount: previousSR, URCount: previousUR, SRSort: -1, URSort: -1}
|
||||
if g.Count <= 0 || len(g.Pool) == 0 {
|
||||
return nil, state, errors.New("gamedata: invalid ticket equipment gacha")
|
||||
}
|
||||
out := make([]uint64, g.Count)
|
||||
for i := range out {
|
||||
id, err := rollEquipmentChoiceWith(g.Pool, cryptoDraw)
|
||||
if err != nil {
|
||||
return nil, state, err
|
||||
}
|
||||
out[i] = id
|
||||
}
|
||||
return out, state, nil
|
||||
}
|
||||
return g.rollWith(previousSR, previousUR, fixed, cryptoDraw)
|
||||
}
|
||||
|
||||
type EquipmentRoll struct {
|
||||
SRCount, URCount uint64
|
||||
SRSort, URSort int
|
||||
}
|
||||
|
||||
func (g EquipmentGacha) rollWith(sr, ur uint64, fixed EquipmentFixedDesign, draw func(uint64) (uint64, error)) ([]uint64, EquipmentRoll, error) {
|
||||
state := EquipmentRoll{SRCount: sr, URCount: ur, SRSort: -1, URSort: -1}
|
||||
if g.Count <= 0 || len(g.Pool) == 0 || len(g.Grades) == 0 {
|
||||
return nil, state, errors.New("gamedata: invalid equipment gacha")
|
||||
}
|
||||
out := make([]uint64, g.Count)
|
||||
for i := range out {
|
||||
forceUR := fixed.URCount != 0 && state.URCount+1 >= fixed.URCount
|
||||
forceSR := !forceUR && fixed.SRCount != 0 && state.SRCount+1 >= fixed.SRCount
|
||||
var id uint64
|
||||
var err error
|
||||
switch {
|
||||
case forceUR:
|
||||
var pool []WeightedEquipment
|
||||
pool, err = g.gradePool(4)
|
||||
if err == nil {
|
||||
id, err = rollEquipmentChoiceWith(pool, draw)
|
||||
}
|
||||
state.URSort = i
|
||||
case forceSR:
|
||||
var pool []WeightedEquipment
|
||||
pool, err = g.gradePool(3)
|
||||
if err == nil {
|
||||
id, err = rollEquipmentChoiceWith(pool, draw)
|
||||
}
|
||||
state.SRSort = i
|
||||
default:
|
||||
id, err = rollEquipmentChoiceWith(g.Pool, draw)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, state, err
|
||||
}
|
||||
out[i] = id
|
||||
grade, known := g.Grades[id]
|
||||
if !known || grade == 0 || grade > 4 {
|
||||
return nil, state, errors.New("gamedata: equipment grade unavailable")
|
||||
}
|
||||
state.SRCount++
|
||||
state.URCount++
|
||||
if forceSR || forceUR || fixed.Reset && grade >= 3 {
|
||||
state.SRCount = 0
|
||||
}
|
||||
if forceUR || fixed.Reset && grade == 4 {
|
||||
state.URCount = 0
|
||||
}
|
||||
|
||||
}
|
||||
return out, state, nil
|
||||
}
|
||||
|
||||
func rollEquipmentChoiceWith(pool []WeightedEquipment, draw func(uint64) (uint64, error)) (uint64, error) {
|
||||
var total uint64
|
||||
for _, item := range pool {
|
||||
if item.Weight == 0 || math.MaxUint64-total < item.Weight {
|
||||
return 0, errors.New("gamedata: invalid equipment weight")
|
||||
}
|
||||
total += item.Weight
|
||||
}
|
||||
value, err := draw(total)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if value >= total {
|
||||
return 0, errors.New("gamedata: random source out of range")
|
||||
}
|
||||
for _, item := range pool {
|
||||
if value < item.Weight {
|
||||
if item.ID != 0 {
|
||||
return item.ID, nil
|
||||
}
|
||||
return rollEquipmentChoiceWith(item.Children, draw)
|
||||
}
|
||||
value -= item.Weight
|
||||
}
|
||||
return 0, errors.New("gamedata: equipment selection failed")
|
||||
}
|
||||
func cryptoDraw(limit uint64) (uint64, error) {
|
||||
n, err := rand.Int(rand.Reader, new(big.Int).SetUint64(limit))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n.Uint64(), nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// Local pity policy: no separate official equipment pity pool is available.
|
||||
// gradePool conditions the actual nested weighted distribution on one grade.
|
||||
// Exact rational path weights preserve branches with different child totals.
|
||||
func (g EquipmentGacha) gradePool(grade uint64) ([]WeightedEquipment, error) {
|
||||
weights := map[uint64]*big.Rat{}
|
||||
var walk func([]WeightedEquipment, *big.Rat) error
|
||||
walk = func(pool []WeightedEquipment, path *big.Rat) error {
|
||||
total := new(big.Int)
|
||||
for _, item := range pool {
|
||||
if item.Weight == 0 {
|
||||
return errors.New("gamedata: zero equipment weight")
|
||||
}
|
||||
total.Add(total, new(big.Int).SetUint64(item.Weight))
|
||||
}
|
||||
if total.Sign() == 0 {
|
||||
return errors.New("gamedata: empty equipment pool")
|
||||
}
|
||||
for _, item := range pool {
|
||||
prob := new(big.Rat).Mul(path, new(big.Rat).SetFrac(new(big.Int).SetUint64(item.Weight), total))
|
||||
if item.ID == 0 {
|
||||
if err := walk(item.Children, prob); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
actual, ok := g.Grades[item.ID]
|
||||
if !ok || actual == 0 || actual > 4 {
|
||||
return errors.New("gamedata: equipment grade unavailable")
|
||||
}
|
||||
if actual == grade {
|
||||
if weights[item.ID] == nil {
|
||||
weights[item.ID] = new(big.Rat)
|
||||
}
|
||||
weights[item.ID].Add(weights[item.ID], prob)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := walk(g.Pool, new(big.Rat).SetInt64(1)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
denom := big.NewInt(1)
|
||||
for _, weight := range weights {
|
||||
gcd := new(big.Int).GCD(nil, nil, denom, weight.Denom())
|
||||
denom.Mul(new(big.Int).Quo(denom, gcd), weight.Denom())
|
||||
}
|
||||
var ids []uint64
|
||||
for id := range weights {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
// Stable table-independent ordering makes deterministic draw sources reproducible.
|
||||
for i := 1; i < len(ids); i++ {
|
||||
for j := i; j > 0 && ids[j] < ids[j-1]; j-- {
|
||||
ids[j], ids[j-1] = ids[j-1], ids[j]
|
||||
}
|
||||
}
|
||||
var out []WeightedEquipment
|
||||
for _, id := range ids {
|
||||
w := weights[id]
|
||||
count := new(big.Int).Mul(w.Num(), new(big.Int).Quo(new(big.Int).Set(denom), w.Denom()))
|
||||
if !count.IsUint64() {
|
||||
return nil, errors.New("gamedata: conditioned equipment weight overflow")
|
||||
}
|
||||
out = append(out, WeightedEquipment{ID: id, Weight: count.Uint64()})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// EquipmentOptionRerollDesign is the static equipment-option refinement
|
||||
// program from GameData. It deliberately does not impose cross-slot
|
||||
// uniqueness or reject the previous option: neither rule is present in the
|
||||
// design tables.
|
||||
type EquipmentOptionRerollDesign struct {
|
||||
Equipment map[uint64]EquipmentOptionRerollItem
|
||||
Costs map[uint64]EquipmentOptionRerollCost
|
||||
Groups map[uint64]OptionGroup
|
||||
Conversion *EquipmentOptionRerollConversion
|
||||
draw func(uint64) (uint64, error)
|
||||
}
|
||||
|
||||
// EquipmentOptionRerollConversion describes the optional material
|
||||
// substitution shown by the client. Ratio Source resources have the value of
|
||||
// one Target resource; requests still carry the concrete stacks consumed.
|
||||
type EquipmentOptionRerollConversion struct {
|
||||
Ratio uint64
|
||||
SourceType, SourceID uint64
|
||||
TargetType, TargetID uint64
|
||||
}
|
||||
|
||||
type EquipmentOptionRerollItem struct {
|
||||
ID, OptionRerollID, PrivateUniqueCharID uint64
|
||||
MainGroups []uint64
|
||||
SubGroups []uint64
|
||||
PrivateGroups []uint64
|
||||
}
|
||||
|
||||
// EquipmentOptionRerollCost holds parallel GameData resource arrays. A
|
||||
// resource costs BaseCount + LockCount*lockedSlots for one refinement.
|
||||
type EquipmentOptionRerollCost struct {
|
||||
Resources []EquipmentOptionRerollResource
|
||||
}
|
||||
|
||||
type EquipmentOptionRerollResource struct {
|
||||
Type, ID, BaseCount, LockCount uint64
|
||||
}
|
||||
|
||||
type EquipmentOptionRerollLocks struct {
|
||||
Main, Sub, Private []bool
|
||||
}
|
||||
|
||||
type EquipmentOptionRerollRoll struct {
|
||||
// Locked entries are left as the zero value. Callers preserve the
|
||||
// corresponding current option when applying the candidate.
|
||||
Main, Sub, Private []EquipmentOptionChoice
|
||||
}
|
||||
|
||||
func LoadEquipmentOptionRerollDesign(root, version string) (*EquipmentOptionRerollDesign, error) {
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
return loadEquipmentOptionRerollDesign(db)
|
||||
}
|
||||
|
||||
func loadEquipmentOptionRerollDesign(db *sql.DB) (*EquipmentOptionRerollDesign, error) {
|
||||
d := &EquipmentOptionRerollDesign{
|
||||
Equipment: make(map[uint64]EquipmentOptionRerollItem),
|
||||
Costs: make(map[uint64]EquipmentOptionRerollCost),
|
||||
Groups: make(map[uint64]OptionGroup),
|
||||
draw: cryptoDraw,
|
||||
}
|
||||
referencedGroups := make(map[uint64]bool)
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM EquipmentTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
protoID, idErr := packedInts(proto, 6)
|
||||
rerollID, rerollErr := packedInts(proto, 15)
|
||||
main, mainErr := packedInts(proto, 12)
|
||||
sub, subErr := packedInts(proto, 21)
|
||||
private, privateErr := packedInts(proto, 17)
|
||||
privateUniqueCharID, uniqueErr := packedInts(proto, 16)
|
||||
if idErr != nil || rerollErr != nil || mainErr != nil || subErr != nil || privateErr != nil || uniqueErr != nil ||
|
||||
len(protoID) != 1 || protoID[0] != id || len(rerollID) != 1 || rerollID[0] == 0 || len(main) == 0 || len(sub) == 0 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment %d has invalid option-reroll design", id)
|
||||
}
|
||||
item := EquipmentOptionRerollItem{
|
||||
ID: id,
|
||||
OptionRerollID: rerollID[0],
|
||||
MainGroups: append([]uint64(nil), main...),
|
||||
SubGroups: append([]uint64(nil), sub...),
|
||||
PrivateGroups: append([]uint64(nil), private...),
|
||||
}
|
||||
if len(privateUniqueCharID) > 1 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment %d has invalid private unique character", id)
|
||||
}
|
||||
if len(privateUniqueCharID) == 1 {
|
||||
item.PrivateUniqueCharID = privateUniqueCharID[0]
|
||||
}
|
||||
for _, group := range append(append(append([]uint64(nil), main...), sub...), private...) {
|
||||
if group == 0 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment %d references option group zero", id)
|
||||
}
|
||||
referencedGroups[group] = true
|
||||
}
|
||||
d.Equipment[id] = item
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM EquipmentOptionRerollTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
protoID, idErr := packedInts(proto, 1)
|
||||
locks, lockErr := packedInts(proto, 2)
|
||||
counts, countErr := packedInts(proto, 3)
|
||||
ids, itemErr := packedInts(proto, 4)
|
||||
types, typeErr := packedInts(proto, 5)
|
||||
if idErr != nil || lockErr != nil || countErr != nil || itemErr != nil || typeErr != nil ||
|
||||
len(protoID) != 1 || protoID[0] != id || len(counts) == 0 || len(locks) != len(counts) || len(ids) != len(counts) || len(types) != len(counts) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: option-reroll cost %d has mismatched resource arrays", id)
|
||||
}
|
||||
cost := EquipmentOptionRerollCost{Resources: make([]EquipmentOptionRerollResource, len(counts))}
|
||||
for i := range counts {
|
||||
if types[i] == 0 || counts[i] == 0 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: option-reroll cost %d has invalid resource %d", id, i)
|
||||
}
|
||||
cost.Resources[i] = EquipmentOptionRerollResource{Type: types[i], ID: ids[i], BaseCount: counts[i], LockCount: locks[i]}
|
||||
}
|
||||
d.Costs[id] = cost
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM EquipmentRerollDefaultTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
ratio, ratioErr := packedInts(proto, 1)
|
||||
sourceID, sourceIDErr := packedInts(proto, 3)
|
||||
sourceType, sourceTypeErr := packedInts(proto, 4)
|
||||
targetID, targetIDErr := packedInts(proto, 5)
|
||||
targetType, targetTypeErr := packedInts(proto, 6)
|
||||
if d.Conversion != nil || ratioErr != nil || sourceIDErr != nil || sourceTypeErr != nil || targetIDErr != nil || targetTypeErr != nil ||
|
||||
len(ratio) != 1 || ratio[0] == 0 || len(sourceID) != 1 || sourceID[0] == 0 || len(sourceType) != 1 || sourceType[0] == 0 ||
|
||||
len(targetID) != 1 || targetID[0] == 0 || len(targetType) != 1 || targetType[0] == 0 ||
|
||||
(sourceID[0] == targetID[0] && sourceType[0] == targetType[0]) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment reroll default %d is invalid", id)
|
||||
}
|
||||
d.Conversion = &EquipmentOptionRerollConversion{
|
||||
Ratio: ratio[0], SourceType: sourceType[0], SourceID: sourceID[0], TargetType: targetType[0], TargetID: targetID[0],
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM EquipmentOptionTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var groupID, id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&groupID, &id, &proto); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if !referencedGroups[groupID] {
|
||||
continue
|
||||
}
|
||||
protoGroup, groupErr := packedInts(proto, 3)
|
||||
protoID, idErr := packedInts(proto, 5)
|
||||
weights, weightErr := packedInts(proto, 2)
|
||||
value, present, valueErr := fixed64Double(proto, 1)
|
||||
if groupErr != nil || idErr != nil || weightErr != nil || valueErr != nil || !present ||
|
||||
len(protoGroup) != 1 || protoGroup[0] != groupID || len(protoID) != 1 || protoID[0] != id || len(weights) != 1 || weights[0] == 0 || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: option group %d choice %d is invalid", groupID, id)
|
||||
}
|
||||
group := d.Groups[groupID]
|
||||
group.ID = groupID
|
||||
group.Choices = append(group.Choices, WeightedOption{ID: id, Weight: weights[0]})
|
||||
d.Groups[groupID] = group
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for id, equipment := range d.Equipment {
|
||||
if len(d.Costs[equipment.OptionRerollID].Resources) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: equipment %d references missing option-reroll cost %d", id, equipment.OptionRerollID)
|
||||
}
|
||||
for _, groupID := range append(append(append([]uint64(nil), equipment.MainGroups...), equipment.SubGroups...), equipment.PrivateGroups...) {
|
||||
if len(d.Groups[groupID].Choices) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: equipment %d references missing option group %d", id, groupID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *EquipmentOptionRerollDesign) Lookup(equipmentID uint64) (EquipmentOptionRerollItem, bool) {
|
||||
if d == nil {
|
||||
return EquipmentOptionRerollItem{}, false
|
||||
}
|
||||
item, ok := d.Equipment[equipmentID]
|
||||
return item, ok
|
||||
}
|
||||
|
||||
func (d *EquipmentOptionRerollDesign) Cost(equipmentID, lockedSlots uint64) ([]PromotionCost, error) {
|
||||
if d == nil {
|
||||
return nil, fmt.Errorf("gamedata: equipment option-reroll design unavailable")
|
||||
}
|
||||
equipment, ok := d.Equipment[equipmentID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("gamedata: unknown equipment %d", equipmentID)
|
||||
}
|
||||
cost, ok := d.Costs[equipment.OptionRerollID]
|
||||
if !ok || len(cost.Resources) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: equipment %d has no option-reroll cost", equipmentID)
|
||||
}
|
||||
result := make([]PromotionCost, len(cost.Resources))
|
||||
for i, resource := range cost.Resources {
|
||||
if resource.LockCount != 0 && lockedSlots > (math.MaxUint64-resource.BaseCount)/resource.LockCount {
|
||||
return nil, fmt.Errorf("gamedata: equipment %d option-reroll cost overflows", equipmentID)
|
||||
}
|
||||
result[i] = PromotionCost{Type: resource.Type, ID: resource.ID, Count: resource.BaseCount + resource.LockCount*lockedSlots}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RollUnlocked independently rolls each unlocked slot from its own GameData
|
||||
// option group. Locked slots are returned as zero choices for the caller to
|
||||
// preserve; no result is excluded because it appeared in another slot or in
|
||||
// the current equipment state.
|
||||
func (d *EquipmentOptionRerollDesign) RollUnlocked(equipmentID uint64, locks EquipmentOptionRerollLocks) (EquipmentOptionRerollRoll, error) {
|
||||
if d == nil {
|
||||
return EquipmentOptionRerollRoll{}, fmt.Errorf("gamedata: equipment option-reroll design unavailable")
|
||||
}
|
||||
equipment, ok := d.Equipment[equipmentID]
|
||||
if !ok {
|
||||
return EquipmentOptionRerollRoll{}, fmt.Errorf("gamedata: unknown equipment %d", equipmentID)
|
||||
}
|
||||
if len(locks.Main) != len(equipment.MainGroups) || len(locks.Sub) != len(equipment.SubGroups) || len(locks.Private) != len(equipment.PrivateGroups) {
|
||||
return EquipmentOptionRerollRoll{}, fmt.Errorf("gamedata: equipment %d option lock arrays do not match slots", equipmentID)
|
||||
}
|
||||
result := EquipmentOptionRerollRoll{
|
||||
Main: make([]EquipmentOptionChoice, len(equipment.MainGroups)),
|
||||
Sub: make([]EquipmentOptionChoice, len(equipment.SubGroups)),
|
||||
Private: make([]EquipmentOptionChoice, len(equipment.PrivateGroups)),
|
||||
}
|
||||
for _, set := range []struct {
|
||||
groups []uint64
|
||||
locks []bool
|
||||
out []EquipmentOptionChoice
|
||||
}{{equipment.MainGroups, locks.Main, result.Main}, {equipment.SubGroups, locks.Sub, result.Sub}, {equipment.PrivateGroups, locks.Private, result.Private}} {
|
||||
for i, groupID := range set.groups {
|
||||
if set.locks[i] {
|
||||
continue
|
||||
}
|
||||
choice, err := d.rollGroup(groupID)
|
||||
if err != nil {
|
||||
return EquipmentOptionRerollRoll{}, fmt.Errorf("gamedata: roll equipment %d option group %d: %w", equipmentID, groupID, err)
|
||||
}
|
||||
set.out[i] = choice
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *EquipmentOptionRerollDesign) rollGroup(groupID uint64) (EquipmentOptionChoice, error) {
|
||||
group, ok := d.Groups[groupID]
|
||||
if !ok || len(group.Choices) == 0 {
|
||||
return EquipmentOptionChoice{}, fmt.Errorf("missing option group %d", groupID)
|
||||
}
|
||||
pool := make([]WeightedEquipment, 0, len(group.Choices))
|
||||
for _, choice := range group.Choices {
|
||||
if choice.Weight == 0 {
|
||||
continue
|
||||
}
|
||||
pool = append(pool, WeightedEquipment{ID: choice.ID, Weight: choice.Weight})
|
||||
}
|
||||
draw := d.draw
|
||||
if draw == nil {
|
||||
draw = cryptoDraw
|
||||
}
|
||||
id, err := rollEquipmentChoiceWith(pool, draw)
|
||||
if err != nil {
|
||||
return EquipmentOptionChoice{}, err
|
||||
}
|
||||
return EquipmentOptionChoice{GroupID: groupID, ID: id}, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
return loadEquipmentSlots(db)
|
||||
}
|
||||
|
||||
func loadEquipmentSlots(db *sql.DB) (map[uint64]uint64, error) {
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM EquipmentTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
slots := make(map[uint64]uint64)
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values, err := packedInts(proto, 20)
|
||||
if err != nil || len(values) > 1 {
|
||||
return nil, fmt.Errorf("gamedata: equipment %d malformed slot: %v", id, err)
|
||||
}
|
||||
var slot uint64
|
||||
if len(values) == 1 {
|
||||
slot = values[0]
|
||||
}
|
||||
if slot > 4 {
|
||||
return nil, fmt.Errorf("gamedata: equipment %d invalid slot %d", id, slot)
|
||||
}
|
||||
slots[id] = slot
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(slots) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: EquipmentTable is empty")
|
||||
}
|
||||
return slots, nil
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// EquipmentSmeltingDesign contains only static 2.35.10 GameData facts. It does
|
||||
// not decide whether a lower-score result is applied or how mileage is paid:
|
||||
// those are server transactions and require an official response capture.
|
||||
type EquipmentSmeltingDesign struct {
|
||||
Equipment map[uint64]EquipmentSmeltingItem
|
||||
Ranks map[[2]uint64]EquipmentSmeltingRank
|
||||
Grades map[uint64][]PromotionCost
|
||||
Mileage EquipmentSmeltingMileage
|
||||
MaxStreak uint64
|
||||
}
|
||||
|
||||
type EquipmentSmeltingItem struct {
|
||||
Grade, RankGroup, MaxLevel uint64
|
||||
}
|
||||
|
||||
type EquipmentSmeltingRank struct {
|
||||
// GrowthPoint is the per-rank battle-power contribution. Values is the
|
||||
// separate 1..4 score used to compare a refinement candidate.
|
||||
GrowthPoint []uint64
|
||||
Values []uint64
|
||||
Ratio []float64
|
||||
}
|
||||
|
||||
type EquipmentSmeltingMileage struct {
|
||||
UseType, UseID, UseCount uint64
|
||||
RewardType, RewardID, RewardCount uint64
|
||||
}
|
||||
|
||||
func LoadEquipmentSmeltingDesign(root, version string) (*EquipmentSmeltingDesign, error) {
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
return loadEquipmentSmeltingDesign(db)
|
||||
}
|
||||
|
||||
func loadEquipmentSmeltingDesign(db *sql.DB) (*EquipmentSmeltingDesign, error) {
|
||||
d := &EquipmentSmeltingDesign{
|
||||
Equipment: make(map[uint64]EquipmentSmeltingItem),
|
||||
Ranks: make(map[[2]uint64]EquipmentSmeltingRank),
|
||||
Grades: make(map[uint64][]PromotionCost),
|
||||
}
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM EquipmentTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
grade, _ := packedInts(proto, 3)
|
||||
maximum, _ := packedInts(proto, 13)
|
||||
rankGroup, _ := packedInts(proto, 19)
|
||||
if len(grade) != 1 || len(maximum) != 1 || len(rankGroup) != 1 || grade[0] == 0 || maximum[0] == 0 || rankGroup[0] == 0 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment %d has invalid smelting design", id)
|
||||
}
|
||||
d.Equipment[id] = EquipmentSmeltingItem{Grade: grade[0], RankGroup: rankGroup[0], MaxLevel: maximum[0]}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM EquipmentGradeTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
counts, _ := packedInts(proto, 4)
|
||||
ids, _ := packedInts(proto, 5)
|
||||
types, _ := packedInts(proto, 6)
|
||||
if len(counts) == 0 || len(counts) != len(ids) || len(counts) != len(types) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment grade %d has invalid smelting costs", id)
|
||||
}
|
||||
for i := range counts {
|
||||
if counts[i] == 0 || (types[i] != 4 && types[i] != 8) || (types[i] == 4 && ids[i] != 0) || (types[i] == 8 && ids[i] == 0) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment grade %d has invalid smelting material", id)
|
||||
}
|
||||
d.Grades[id] = append(d.Grades[id], PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]})
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM EquipmentRankTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var group, slot uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&group, &slot, &proto); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
ratios, err := fixed32Floats(proto, 4)
|
||||
growth, growthErr := packedInts(proto, 2)
|
||||
values, valueErr := packedInts(proto, 5)
|
||||
if err != nil || growthErr != nil || valueErr != nil || slot < 1 || slot > 3 || len(ratios) != 4 || len(growth) != 4 || len(values) != 4 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment rank %d/%d has invalid smelting distribution", group, slot)
|
||||
}
|
||||
var total float64
|
||||
for i := range ratios {
|
||||
if ratios[i] < 0 || ratios[i] > 1 || math.IsNaN(ratios[i]) || values[i] == 0 || growth[i] == 0 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment rank %d/%d has invalid smelting value", group, slot)
|
||||
}
|
||||
total += ratios[i]
|
||||
if i != 0 && (values[i] <= values[i-1] || growth[i] <= growth[i-1]) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment rank %d/%d is not increasing", group, slot)
|
||||
}
|
||||
}
|
||||
if math.Abs(total-1) > 1e-5 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment rank %d/%d ratio total %.8f", group, slot, total)
|
||||
}
|
||||
d.Ranks[[2]uint64{group, slot}] = EquipmentSmeltingRank{GrowthPoint: growth, Values: values, Ratio: ratios}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for id, equipment := range d.Equipment {
|
||||
if len(d.Grades[equipment.Grade]) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: equipment %d has no grade costs", id)
|
||||
}
|
||||
for slot := uint64(1); slot <= 3; slot++ {
|
||||
if len(d.Ranks[[2]uint64{equipment.RankGroup, slot}].Values) != 4 {
|
||||
return nil, fmt.Errorf("gamedata: equipment %d has no rank slot %d", id, slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
var mileageProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM EquipmentMileageTable WHERE id=0").Scan(&mileageProto); err != nil {
|
||||
return nil, fmt.Errorf("gamedata: equipment mileage: %w", err)
|
||||
}
|
||||
mileageCount, _ := packedInts(mileageProto, 2)
|
||||
mileageID, _ := packedInts(mileageProto, 3)
|
||||
mileageType, _ := packedInts(mileageProto, 4)
|
||||
useCount, _ := packedInts(mileageProto, 5)
|
||||
useID, _ := packedInts(mileageProto, 6)
|
||||
useType, _ := packedInts(mileageProto, 7)
|
||||
if len(mileageCount) != 1 || len(mileageID) > 1 || len(mileageType) != 1 || len(useCount) != 1 || len(useID) != 1 || len(useType) != 1 || mileageCount[0] == 0 || useCount[0] == 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid equipment mileage definition")
|
||||
}
|
||||
if len(mileageID) == 1 {
|
||||
d.Mileage.RewardID = mileageID[0]
|
||||
}
|
||||
d.Mileage.RewardCount, d.Mileage.RewardType = mileageCount[0], mileageType[0]
|
||||
d.Mileage.UseCount, d.Mileage.UseID, d.Mileage.UseType = useCount[0], useID[0], useType[0]
|
||||
if d.Mileage.RewardType == 0 || d.Mileage.UseType == 0 || d.Mileage.UseID == 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid equipment mileage item types")
|
||||
}
|
||||
for grade, costs := range d.Grades {
|
||||
found := false
|
||||
for _, cost := range costs {
|
||||
if cost.Type == d.Mileage.UseType && cost.ID == d.Mileage.UseID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("gamedata: equipment grade %d does not consume mileage material", grade)
|
||||
}
|
||||
}
|
||||
var defaults []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&defaults); err != nil {
|
||||
return nil, fmt.Errorf("gamedata: equipment smelting limit: %w", err)
|
||||
}
|
||||
maxStreak, _ := packedInts(defaults, 5)
|
||||
if len(maxStreak) != 1 || maxStreak[0] == 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid maximum smelting streak")
|
||||
}
|
||||
d.MaxStreak = maxStreak[0]
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *EquipmentSmeltingDesign) Cost(equipmentID uint64) ([]PromotionCost, error) {
|
||||
if d == nil {
|
||||
return nil, fmt.Errorf("gamedata: equipment smelting design unavailable")
|
||||
}
|
||||
equipment, ok := d.Equipment[equipmentID]
|
||||
if !ok || len(d.Grades[equipment.Grade]) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: equipment %d has no smelting cost", equipmentID)
|
||||
}
|
||||
return append([]PromotionCost(nil), d.Grades[equipment.Grade]...), nil
|
||||
}
|
||||
|
||||
func (d *EquipmentSmeltingDesign) Score(equipmentID uint64, ranks []uint64) (uint64, error) {
|
||||
if d == nil || len(ranks) != 3 {
|
||||
return 0, fmt.Errorf("gamedata: invalid equipment rank score input")
|
||||
}
|
||||
equipment, ok := d.Equipment[equipmentID]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("gamedata: equipment %d has no smelting design", equipmentID)
|
||||
}
|
||||
var score uint64
|
||||
for i, rank := range ranks {
|
||||
values := d.Ranks[[2]uint64{equipment.RankGroup, uint64(i + 1)}].Values
|
||||
if rank < 1 || rank > uint64(len(values)) || values[rank-1] > ^uint64(0)-score {
|
||||
return 0, fmt.Errorf("gamedata: invalid equipment rank %d in slot %d", rank, i+1)
|
||||
}
|
||||
score += values[rank-1]
|
||||
}
|
||||
return score, nil
|
||||
}
|
||||
|
||||
func (d *EquipmentSmeltingDesign) MaximumRanks(equipmentID uint64) ([]uint64, error) {
|
||||
if d == nil {
|
||||
return nil, fmt.Errorf("gamedata: equipment smelting design unavailable")
|
||||
}
|
||||
equipment, ok := d.Equipment[equipmentID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("gamedata: equipment %d has no smelting design", equipmentID)
|
||||
}
|
||||
ranks := make([]uint64, 3)
|
||||
for slot := uint64(1); slot <= 3; slot++ {
|
||||
values := d.Ranks[[2]uint64{equipment.RankGroup, slot}].Values
|
||||
if len(values) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: equipment %d has no rank slot %d", equipmentID, slot)
|
||||
}
|
||||
ranks[slot-1] = uint64(len(values))
|
||||
}
|
||||
return ranks, nil
|
||||
}
|
||||
|
||||
// RollCandidate independently rolls all three rank slots from the official
|
||||
// EquipmentRankTable distributions. Whether the candidate replaces the
|
||||
// current ranks is a player-state transaction, not a GameData concern.
|
||||
func (d *EquipmentSmeltingDesign) RollCandidate(equipmentID uint64) ([]uint64, error) {
|
||||
if d == nil {
|
||||
return nil, fmt.Errorf("gamedata: equipment smelting design unavailable")
|
||||
}
|
||||
equipment, ok := d.Equipment[equipmentID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("gamedata: equipment %d has no smelting design", equipmentID)
|
||||
}
|
||||
result := make([]uint64, 3)
|
||||
for slot := uint64(1); slot <= 3; slot++ {
|
||||
rank := d.Ranks[[2]uint64{equipment.RankGroup, slot}]
|
||||
value, err := cryptoRankRoll(rank.Ratio)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: roll equipment %d rank slot %d: %w", equipmentID, slot, err)
|
||||
}
|
||||
result[slot-1] = value
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// EquipmentStatRule retains the exact option curve. Percent values are already
|
||||
// fractions in GameData: 0.0135 means 1.35%, with no conversion by 100.
|
||||
type EquipmentStatRule struct {
|
||||
Default, Growth float64
|
||||
Levels []float64
|
||||
Ranks [3][]float64
|
||||
}
|
||||
|
||||
// EquipmentStatDesign contains the HP subset used for field maximum health.
|
||||
// Loading once avoids decrypting common.db on every character lookup.
|
||||
type EquipmentStatDesign struct {
|
||||
Options map[[2]uint64]EquipmentStatRule
|
||||
}
|
||||
|
||||
func LoadEquipmentStatDesign(root, version string) (*EquipmentStatDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
return loadEquipmentStatDesign(db)
|
||||
}
|
||||
|
||||
func loadEquipmentStatDesign(db *sql.DB) (*EquipmentStatDesign, error) {
|
||||
rows, err := db.Query("SELECT groupId,id,ProtoBuf FROM EquipmentOptionTable WHERE id IN (1,2)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
d := &EquipmentStatDesign{Options: map[[2]uint64]EquipmentStatRule{}}
|
||||
for rows.Next() {
|
||||
var group, id uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&group, &id, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
protoGroup, err := packedInts(raw, 3)
|
||||
if err != nil || len(protoGroup) != 1 || protoGroup[0] != group {
|
||||
return nil, fmt.Errorf("gamedata: invalid equipment stat group %d", group)
|
||||
}
|
||||
protoID, err := packedInts(raw, 5)
|
||||
if err != nil || len(protoID) != 1 || protoID[0] != id {
|
||||
return nil, fmt.Errorf("gamedata: invalid equipment stat id %d/%d", group, id)
|
||||
}
|
||||
var rule EquipmentStatRule
|
||||
rule.Default, _, err = fixed64Double(raw, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rule.Growth, _, err = fixed64Double(raw, 4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rule.Levels, err = fixed32Floats(raw, 6)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range rule.Ranks {
|
||||
rule.Ranks[i], err = fixed32Floats(raw, 7+i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
values := []float64{rule.Default, rule.Growth}
|
||||
values = append(values, rule.Levels...)
|
||||
for _, ranks := range rule.Ranks {
|
||||
values = append(values, ranks...)
|
||||
}
|
||||
for _, value := range values {
|
||||
if value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return nil, fmt.Errorf("gamedata: invalid equipment stat %d/%d", group, id)
|
||||
}
|
||||
}
|
||||
d.Options[[2]uint64{group, id}] = rule
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(d.Options) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: empty equipment health stat design")
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// HealthContribution follows EquipmentInfo: main/private options use the
|
||||
// level-and-rank curve, while suboptions use DefaultValue alone.
|
||||
func (d *EquipmentStatDesign) HealthContribution(option EquipmentOption, suboption bool) (StatContribution, error) {
|
||||
if option.ID == 0 || option.ID > 2 {
|
||||
return StatContribution{}, nil
|
||||
}
|
||||
if d == nil || option.GroupID == 0 || option.Level < 0 {
|
||||
return StatContribution{}, fmt.Errorf("gamedata: invalid equipment health stat option")
|
||||
}
|
||||
rule, found := d.Options[[2]uint64{option.GroupID, option.ID}]
|
||||
if !found {
|
||||
return StatContribution{}, fmt.Errorf("gamedata: unknown equipment health option %d/%d", option.GroupID, option.ID)
|
||||
}
|
||||
return equipmentStatContribution(rule, option, suboption)
|
||||
}
|
||||
|
||||
func equipmentStatContribution(rule EquipmentStatRule, option EquipmentOption, suboption bool) (StatContribution, error) {
|
||||
value := rule.Default
|
||||
if !suboption {
|
||||
var level float64
|
||||
if len(rule.Levels) != 0 {
|
||||
if option.Level >= len(rule.Levels) {
|
||||
return StatContribution{}, fmt.Errorf("gamedata: invalid equipment health option level %d", option.Level)
|
||||
}
|
||||
level = rule.Levels[option.Level]
|
||||
}
|
||||
var ranks float32
|
||||
for i, rank := range option.Rank {
|
||||
// EquipmentInfo.GetEquipOptionByRank returns zero for an empty
|
||||
// RankValueN curve, even when the equipment has a rank there.
|
||||
if rank == 0 || len(rule.Ranks[i]) == 0 {
|
||||
continue
|
||||
}
|
||||
if rank < 1 || rank > len(rule.Ranks[i]) {
|
||||
return StatContribution{}, fmt.Errorf("gamedata: invalid equipment health option rank %d", rank)
|
||||
}
|
||||
ranks += float32(rule.Ranks[i][rank-1])
|
||||
}
|
||||
value += rule.Growth * (level + float64(ranks))
|
||||
}
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 {
|
||||
return StatContribution{}, fmt.Errorf("gamedata: invalid equipment health stat result")
|
||||
}
|
||||
return optionContribution(option.ID, value)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
type EquipmentUpgradeLevel struct {
|
||||
Level, GrowthPoint uint64
|
||||
Costs []PromotionCost
|
||||
SuccessRatio float64
|
||||
}
|
||||
|
||||
type EquipmentUpgradeDesign struct {
|
||||
MaxLevel map[uint64]uint64
|
||||
Group map[uint64]uint64
|
||||
RankGroup map[uint64]uint64
|
||||
Levels map[[2]uint64]EquipmentUpgradeLevel
|
||||
RankRatio map[[2]uint64][]float64
|
||||
Break map[[2]uint64][]BattleReward
|
||||
NotTrash map[uint64]bool
|
||||
roll func(float64) (bool, error)
|
||||
rankRoll func([]float64) (uint64, error)
|
||||
}
|
||||
|
||||
func LoadEquipmentUpgradeDesign(root, version string) (*EquipmentUpgradeDesign, error) {
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
return loadEquipmentUpgradeDesign(db)
|
||||
}
|
||||
|
||||
func loadEquipmentUpgradeDesign(db *sql.DB) (*EquipmentUpgradeDesign, error) {
|
||||
d := &EquipmentUpgradeDesign{
|
||||
MaxLevel: map[uint64]uint64{}, Group: map[uint64]uint64{}, RankGroup: map[uint64]uint64{},
|
||||
Levels: map[[2]uint64]EquipmentUpgradeLevel{}, RankRatio: map[[2]uint64][]float64{},
|
||||
Break: map[[2]uint64][]BattleReward{}, NotTrash: map[uint64]bool{},
|
||||
roll: cryptoRatioRoll, rankRoll: cryptoRankRoll,
|
||||
}
|
||||
groupMaximum := map[uint64]uint64{}
|
||||
rankGroups := map[uint64]bool{}
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM EquipmentTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
groups, _ := packedInts(proto, 4)
|
||||
maximum, _ := packedInts(proto, 13)
|
||||
notTrash, _ := packedInts(proto, 14)
|
||||
rankGroup, _ := packedInts(proto, 19)
|
||||
if len(groups) != 1 || len(maximum) != 1 || len(rankGroup) != 1 || maximum[0] == 0 || rankGroup[0] == 0 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment %d invalid upgrade design", id)
|
||||
}
|
||||
d.Group[id], d.MaxLevel[id], d.RankGroup[id] = groups[0], maximum[0], rankGroup[0]
|
||||
d.NotTrash[id] = len(notTrash) == 1 && notTrash[0] != 0
|
||||
rankGroups[rankGroup[0]] = true
|
||||
if prior, found := groupMaximum[groups[0]]; found && prior != maximum[0] {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment growth group %d has inconsistent maxima", groups[0])
|
||||
}
|
||||
groupMaximum[groups[0]] = maximum[0]
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM EquipmentGrowthTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var group, level uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&group, &level, &proto); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
maximum, referenced := groupMaximum[group]
|
||||
if !referenced {
|
||||
continue
|
||||
}
|
||||
breakCounts, _ := packedInts(proto, 1)
|
||||
breakIDs, _ := packedInts(proto, 2)
|
||||
breakTypes, _ := packedInts(proto, 3)
|
||||
if len(breakCounts) == 0 || len(breakCounts) != len(breakIDs) || len(breakCounts) != len(breakTypes) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment break result %d/%d malformed", group, level)
|
||||
}
|
||||
breakRewards := make([]BattleReward, 0, len(breakCounts))
|
||||
for i := range breakCounts {
|
||||
if breakCounts[i] == 0 || breakIDs[i] == 0 || breakTypes[i] == 0 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment break result %d/%d invalid", group, level)
|
||||
}
|
||||
breakRewards = append(breakRewards, BattleReward{Type: breakTypes[i], ID: breakIDs[i], Count: breakCounts[i]})
|
||||
}
|
||||
d.Break[[2]uint64{group, level}] = breakRewards
|
||||
// EquipmentGrowthTable includes the terminal maximum-level row, which
|
||||
// intentionally has no next-upgrade cost or success ratio.
|
||||
if level >= maximum {
|
||||
continue
|
||||
}
|
||||
counts, _ := packedInts(proto, 7)
|
||||
ids, _ := packedInts(proto, 8)
|
||||
types, _ := packedInts(proto, 9)
|
||||
point, _ := packedInts(proto, 5)
|
||||
ratio, present, err := fixed64Double(proto, 10)
|
||||
if err != nil || !present || ratio < 0 || ratio > 1 || len(point) != 1 || len(counts) == 0 || len(counts) != len(ids) || len(counts) != len(types) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment growth %d/%d malformed", group, level)
|
||||
}
|
||||
entry := EquipmentUpgradeLevel{Level: level, GrowthPoint: point[0], SuccessRatio: ratio}
|
||||
for i := range counts {
|
||||
if counts[i] == 0 || (types[i] != 4 && types[i] != 8) || (types[i] == 4 && ids[i] != 0) || (types[i] == 8 && ids[i] == 0) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment growth %d/%d invalid cost", group, level)
|
||||
}
|
||||
entry.Costs = append(entry.Costs, PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]})
|
||||
}
|
||||
d.Levels[[2]uint64{group, level}] = entry
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM EquipmentRankTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var group, slot uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&group, &slot, &proto); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if !rankGroups[group] {
|
||||
continue
|
||||
}
|
||||
ratios, err := fixed32Floats(proto, 4)
|
||||
if err != nil || slot < 1 || slot > 3 || len(ratios) != 4 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment rank %d/%d malformed", group, slot)
|
||||
}
|
||||
var total float64
|
||||
for _, ratio := range ratios {
|
||||
if ratio < 0 || ratio > 1 || math.IsNaN(ratio) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment rank %d/%d invalid ratio", group, slot)
|
||||
}
|
||||
total += ratio
|
||||
}
|
||||
if math.Abs(total-1) > 1e-5 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment rank %d/%d ratio total %.8f", group, slot, total)
|
||||
}
|
||||
d.RankRatio[[2]uint64{group, slot}] = append([]float64(nil), ratios...)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for group := range rankGroups {
|
||||
for slot := uint64(1); slot <= 3; slot++ {
|
||||
if len(d.RankRatio[[2]uint64{group, slot}]) != 4 {
|
||||
return nil, fmt.Errorf("gamedata: missing equipment rank %d/%d", group, slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *EquipmentUpgradeDesign) CanBreak(equipmentID uint64) bool {
|
||||
if d == nil || equipmentID == 0 {
|
||||
return false
|
||||
}
|
||||
// EquipmentTable.NotTrash controls the ordinary discard button. The
|
||||
// client's batch break predicate is instead EquipDBInfo.IsAvailableBreak
|
||||
// (not equipped and not locked), so crafting results with NotTrash=1 are
|
||||
// still valid inputs to EquipMakingToBreakAuto.
|
||||
_, exists := d.Group[equipmentID]
|
||||
return exists
|
||||
}
|
||||
|
||||
func (d *EquipmentUpgradeDesign) BreakRewards(equipmentID, level uint64) ([]BattleReward, error) {
|
||||
if !d.CanBreak(equipmentID) {
|
||||
return nil, fmt.Errorf("gamedata: equipment %d cannot be broken", equipmentID)
|
||||
}
|
||||
maximum := d.MaxLevel[equipmentID]
|
||||
if level > maximum {
|
||||
return nil, fmt.Errorf("gamedata: equipment %d invalid break level %d", equipmentID, level)
|
||||
}
|
||||
rewards, exists := d.Break[[2]uint64{d.Group[equipmentID], level}]
|
||||
if !exists || len(rewards) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: missing equipment break result %d/%d", d.Group[equipmentID], level)
|
||||
}
|
||||
return append([]BattleReward(nil), rewards...), nil
|
||||
}
|
||||
|
||||
func (d *EquipmentUpgradeDesign) Level(equipmentID, level uint64) (EquipmentUpgradeLevel, uint64, error) {
|
||||
if d == nil || equipmentID == 0 {
|
||||
return EquipmentUpgradeLevel{}, 0, fmt.Errorf("gamedata: invalid equipment upgrade request")
|
||||
}
|
||||
maximum, exists := d.MaxLevel[equipmentID]
|
||||
if !exists {
|
||||
return EquipmentUpgradeLevel{}, 0, fmt.Errorf("gamedata: unknown equipment %d", equipmentID)
|
||||
}
|
||||
if level >= maximum {
|
||||
return EquipmentUpgradeLevel{}, maximum, fmt.Errorf("gamedata: equipment %d already at maximum level %d", equipmentID, maximum)
|
||||
}
|
||||
entry, exists := d.Levels[[2]uint64{d.Group[equipmentID], level}]
|
||||
if !exists {
|
||||
return EquipmentUpgradeLevel{}, maximum, fmt.Errorf("gamedata: missing equipment growth %d/%d", d.Group[equipmentID], level)
|
||||
}
|
||||
return entry, maximum, nil
|
||||
}
|
||||
|
||||
func (d *EquipmentUpgradeDesign) Roll(ratio float64) (bool, error) {
|
||||
if d == nil {
|
||||
return false, fmt.Errorf("gamedata: equipment upgrade roller unavailable")
|
||||
}
|
||||
if d.roll == nil {
|
||||
return cryptoRatioRoll(ratio)
|
||||
}
|
||||
return d.roll(ratio)
|
||||
}
|
||||
|
||||
// RollRank returns the official C/B/A/S grade number (1..4) for the slot
|
||||
// unlocked at +3, +6 or +9. The distributions come from EquipmentRankTable.
|
||||
func (d *EquipmentUpgradeDesign) RollRank(equipmentID, slot uint64) (uint64, error) {
|
||||
if d == nil || slot < 1 || slot > 3 {
|
||||
return 0, fmt.Errorf("gamedata: invalid equipment rank request")
|
||||
}
|
||||
group, exists := d.RankGroup[equipmentID]
|
||||
if !exists {
|
||||
return 0, fmt.Errorf("gamedata: unknown equipment rank design %d", equipmentID)
|
||||
}
|
||||
ratios := d.RankRatio[[2]uint64{group, slot}]
|
||||
if len(ratios) != 4 {
|
||||
return 0, fmt.Errorf("gamedata: missing equipment rank %d/%d", group, slot)
|
||||
}
|
||||
if d.rankRoll != nil {
|
||||
return d.rankRoll(ratios)
|
||||
}
|
||||
return cryptoRankRoll(ratios)
|
||||
}
|
||||
|
||||
func cryptoRatioRoll(ratio float64) (bool, error) {
|
||||
if ratio <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
if ratio >= 1 {
|
||||
return true, nil
|
||||
}
|
||||
value, err := cryptoUnitFloat()
|
||||
return value < ratio && !math.IsNaN(ratio), err
|
||||
}
|
||||
|
||||
func cryptoRankRoll(ratios []float64) (uint64, error) {
|
||||
if len(ratios) != 4 {
|
||||
return 0, fmt.Errorf("gamedata: invalid equipment rank ratios")
|
||||
}
|
||||
value, err := cryptoUnitFloat()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var cumulative float64
|
||||
for i, ratio := range ratios {
|
||||
cumulative += ratio
|
||||
if value < cumulative || i == len(ratios)-1 {
|
||||
return uint64(i + 1), nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("gamedata: equipment rank roll failed")
|
||||
}
|
||||
|
||||
func cryptoUnitFloat() (float64, error) {
|
||||
var raw [8]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
value := binary.LittleEndian.Uint64(raw[:]) >> 11
|
||||
return float64(value) / float64(uint64(1)<<53), nil
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/protocol/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", "CharGroupTable", "CharTable", "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 { //nolint:staticcheck // QF1003
|
||||
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}) //nolint:staticcheck // S1016
|
||||
}
|
||||
}
|
||||
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}) //nolint:staticcheck // S1016
|
||||
}
|
||||
}
|
||||
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,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/protocol/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 func() { _ = 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 { //nolint:staticcheck // QF1003
|
||||
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,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 func() { _ = 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,88 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// EventFieldPack combines installed hidden-pack rules with a current calendar
|
||||
// identity. Calendar times use milliseconds, as do event schedule protocols.
|
||||
type EventFieldPack struct {
|
||||
ID int
|
||||
ScheduleUID, GameID, HubID uint64
|
||||
MapIDs []int
|
||||
InitialPosition string
|
||||
InitialMapID, PointPositionID uint64
|
||||
BuyType, BuyPrice uint64
|
||||
ContentOpenType uint64
|
||||
BuyRewards []Reward
|
||||
End int64
|
||||
}
|
||||
|
||||
func loadEventFieldPacks(db *sql.DB) (map[int]EventFieldPack, error) {
|
||||
packs := map[int]EventFieldPack{}
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM PackTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var raw []byte
|
||||
if err = rows.Scan(&id, &raw); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
kind, e := optionalScalar(raw, 55)
|
||||
if e != nil {
|
||||
_ = rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
if kind != 100 {
|
||||
continue
|
||||
}
|
||||
protoID, e := optionalScalar(raw, 25)
|
||||
if e != nil || protoID != uint64(id) || id <= 0 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid hidden pack %d", id)
|
||||
}
|
||||
p := EventFieldPack{ID: id, InitialPosition: "{}"}
|
||||
if p.BuyType, err = optionalScalar(raw, 11); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if p.BuyPrice, err = optionalScalar(raw, 7); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rewards, e := eventGameRewards(raw, 10, 9, 8)
|
||||
if e != nil {
|
||||
_ = rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
for _, r := range rewards {
|
||||
p.BuyRewards = append(p.BuyRewards, Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016
|
||||
}
|
||||
packs[id] = p
|
||||
}
|
||||
err = rows.Err()
|
||||
_ = rows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT id,packId FROM MapTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var id, packID int
|
||||
if err = rows.Scan(&id, &packID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p, ok := packs[packID]; ok {
|
||||
p.MapIDs = append(p.MapIDs, id)
|
||||
packs[packID] = p
|
||||
}
|
||||
}
|
||||
return packs, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
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 func() { _ = 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})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
|
||||
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", 69: "EquipmentRankChangeItemTable"} {
|
||||
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,107 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// EventPlayCatalog keeps the installed static rows for event gameplay.
|
||||
type EventPlayCatalog struct {
|
||||
Tables map[string][][]byte
|
||||
FieldPacks map[int]EventFieldPack
|
||||
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}
|
||||
c.FieldPacks, e = loadEventFieldPacks(db)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
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"}
|
||||
names = append(names, "EventLostCoinTable")
|
||||
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 func() { _ = 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,236 @@
|
||||
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, CashGroup, CashID, CashSales uint64
|
||||
// PassBuyTable.UnlockLevel is the number of levels granted by a purchase.
|
||||
// LocalText 2403 displays it as "Pass Level +{0}", not a prerequisite.
|
||||
LevelsGranted 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 func() { _ = 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.LevelsGranted, 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}) //nolint:staticcheck // S1016
|
||||
}
|
||||
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,86 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type FieldActionObject struct {
|
||||
ID, GroupID, MapID, QuestID, QuestEnableType, Type, RegenSeconds int
|
||||
}
|
||||
|
||||
func loadFieldActionObjects(db *sql.DB) (map[int]FieldActionObject, error) {
|
||||
out := map[int]FieldActionObject{}
|
||||
var tables int
|
||||
if err := db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('FieldActionObjectTable','FieldActionObjectGroupTable')").Scan(&tables); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tables == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if tables != 2 {
|
||||
return nil, fmt.Errorf("gamedata: incomplete field action tables")
|
||||
}
|
||||
groups := map[int]FieldActionObject{}
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM FieldActionObjectGroupTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var raw []byte
|
||||
if err = rows.Scan(&id, &raw); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
group := FieldActionObject{GroupID: id}
|
||||
for field, dst := range map[int]*int{3: &group.RegenSeconds, 4: &group.Type} {
|
||||
v, e := optionalScalar(raw, field)
|
||||
if e != nil || v > 0x7fffffff {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid action group %d", id)
|
||||
}
|
||||
*dst = int(v)
|
||||
}
|
||||
if id <= 0 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid action group identity")
|
||||
}
|
||||
groups[id] = group
|
||||
}
|
||||
err = rows.Err()
|
||||
_ = rows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM FieldActionObjectTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var raw []byte
|
||||
if err = rows.Scan(&id, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupID, e := optionalScalar(raw, 4)
|
||||
group, found := groups[int(groupID)]
|
||||
if e != nil || groupID > 0x7fffffff || !found || id <= 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid action object group %d", id)
|
||||
}
|
||||
group.ID = id
|
||||
for field, dst := range map[int]*int{8: &group.MapID, 10: &group.QuestEnableType, 11: &group.QuestID} {
|
||||
v, e := optionalScalar(raw, field)
|
||||
if e != nil || v > 0x7fffffff {
|
||||
return nil, fmt.Errorf("gamedata: invalid action object %d", id)
|
||||
}
|
||||
*dst = int(v)
|
||||
}
|
||||
if group.MapID <= 0 || group.QuestEnableType > 2 {
|
||||
return nil, fmt.Errorf("gamedata: invalid action object map/quest %d", id)
|
||||
}
|
||||
out[id] = group
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/protocol/wire"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
type FieldBuffDesign struct {
|
||||
ID, Type, TargetType uint64
|
||||
Value float64
|
||||
Time float64
|
||||
}
|
||||
|
||||
func optionalDouble(raw []byte, field int) (float64, error) {
|
||||
var value float64
|
||||
e := wire.Walk(raw, func(f wire.Field) error {
|
||||
if f.Number == field {
|
||||
if f.Type != 1 {
|
||||
return fmt.Errorf("gamedata: invalid double field")
|
||||
}
|
||||
value = math.Float64frombits(binary.LittleEndian.Uint64(f.Value))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return value, e
|
||||
}
|
||||
|
||||
func LoadFieldBuffDesign(root, version string) (map[uint64]FieldBuffDesign, error) {
|
||||
db, done, e := openStatDatabase(root, version)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer done()
|
||||
rows, e := db.Query("SELECT id,ProtoBuf FROM FieldBuffTable ORDER BY id")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := map[uint64]FieldBuffDesign{}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if e = rows.Scan(&id, &raw); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
r := FieldBuffDesign{ID: id}
|
||||
for f, dst := range map[int]*uint64{6: &r.Type, 13: &r.TargetType} {
|
||||
v, x := optionalScalar(raw, f)
|
||||
if x != nil {
|
||||
return nil, x
|
||||
}
|
||||
*dst = v
|
||||
}
|
||||
v, x := optionalDouble(raw, 14)
|
||||
if x != nil {
|
||||
return nil, x
|
||||
}
|
||||
r.Value = v
|
||||
if r.Time, e = optionalDouble(raw, 5); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid field buff")
|
||||
}
|
||||
out[id] = r
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/protocol/wire"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// LoadFieldMonsterMaps resolves actual scene placements, not optional map ids
|
||||
// guessed from the monster definition or arithmetic on its id.
|
||||
func LoadFieldMonsterMaps(root, version string, pack int) (map[int][]int, error) {
|
||||
common, done, e := openStatDatabase(root, version)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer done()
|
||||
maps := map[string]int{}
|
||||
rows, e := common.Query("SELECT id,mapScenePath FROM MapTable WHERE packId=?", pack)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var scene string
|
||||
if e = rows.Scan(&id, &scene); e != nil {
|
||||
_ = rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
maps[scene] = id
|
||||
}
|
||||
e = rows.Err()
|
||||
_ = rows.Close()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
scenes, closeScenes, e := openLogicalReadDB(root, version, "FieldObjectSceneData")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer closeScenes()
|
||||
rows, e = scenes.Query(fmt.Sprintf("SELECT ProtoBuf FROM FieldObjectSceneData%d WHERE objectType=8", pack))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := map[int][]int{}
|
||||
for rows.Next() {
|
||||
var b []byte
|
||||
if e = rows.Scan(&b); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
id, e := optionalScalar(b, 8)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
scene, _, e := wire.Bytes(b, 7)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
mapID, ok := maps[string(scene)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("gamedata: monster scene without map")
|
||||
}
|
||||
out[int(id)] = append(out[int(id)], mapID)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// FieldMonsterDesign keeps the regeneration group requested by MonsterInfo,
|
||||
// not the unrelated event monster group used by FieldEventSpawnReward.
|
||||
type FieldMonsterDesign struct {
|
||||
ID, GroupID, QuestID int
|
||||
BattleDeck uint64
|
||||
BattleDecks []uint64
|
||||
LifeSeconds uint64
|
||||
RegenSeconds, ResetType, Type, FieldBuff, UseBattleSkip, CrashType uint64
|
||||
Reward Reward
|
||||
}
|
||||
|
||||
func LoadFieldMonsters(root, version string, pack int) ([]FieldMonsterDesign, error) {
|
||||
db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", pack))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
return loadFieldMonsters(db)
|
||||
}
|
||||
|
||||
func loadFieldMonsters(db *sql.DB) ([]FieldMonsterDesign, error) {
|
||||
groups := map[int]FieldMonsterDesign{}
|
||||
rows, err := db.Query("SELECT id, ProtoBuf FROM FieldMonsterRegenTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var raw []byte
|
||||
if err = rows.Scan(&id, &raw); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
q, e1 := packedInts(raw, 6)
|
||||
life, e2 := packedInts(raw, 3)
|
||||
if e1 != nil || e2 != nil || len(q) > 1 || len(life) > 1 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: malformed monster regeneration %d", id)
|
||||
}
|
||||
g := FieldMonsterDesign{GroupID: id}
|
||||
for f, dst := range map[int]*uint64{7: &g.RegenSeconds, 9: &g.ResetType} {
|
||||
v, e := optionalScalar(raw, f)
|
||||
if e != nil {
|
||||
_ = rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
*dst = v
|
||||
}
|
||||
if g.ResetType > 2 {
|
||||
return nil, fmt.Errorf("gamedata: unknown monster reset type")
|
||||
}
|
||||
if len(q) > 0 {
|
||||
g.QuestID = int(q[0])
|
||||
}
|
||||
if len(life) > 0 {
|
||||
if life[0] > math.MaxInt32 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid monster lifetime %d", id)
|
||||
}
|
||||
g.LifeSeconds = life[0]
|
||||
}
|
||||
groups[id] = g
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
_ = rows.Close()
|
||||
rows, err = db.Query("SELECT id, ProtoBuf FROM FieldMonsterTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var result []FieldMonsterDesign
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var raw []byte
|
||||
if err = rows.Scan(&id, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
group, e1 := packedInts(raw, 24)
|
||||
decks, e2 := packedInts(raw, 2)
|
||||
if e1 != nil || e2 != nil || len(group) > 1 {
|
||||
return nil, fmt.Errorf("gamedata: malformed field monster %d", id)
|
||||
}
|
||||
g := FieldMonsterDesign{}
|
||||
if len(group) > 0 && group[0] > 0 {
|
||||
var ok bool
|
||||
g, ok = groups[int(group[0])]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("gamedata: monster %d references missing regeneration %d", id, group[0])
|
||||
}
|
||||
}
|
||||
g.ID = id
|
||||
for f, dst := range map[int]*uint64{30: &g.Type, 6: &g.FieldBuff, 15: &g.CrashType, 31: &g.UseBattleSkip, 27: &g.Reward.Count, 28: &g.Reward.ID, 29: &g.Reward.Type} {
|
||||
v, e := optionalScalar(raw, f)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
*dst = v
|
||||
}
|
||||
if len(decks) > 0 {
|
||||
g.BattleDeck = decks[0]
|
||||
}
|
||||
g.BattleDecks = append([]uint64(nil), decks...)
|
||||
result = append(result, g)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// FieldObjectDesign retains the client table identities, including unsupported
|
||||
// reward graphs, so callers can reject them without inventing a reward.
|
||||
type FieldObjectDesign struct {
|
||||
Objects map[int]FieldRewardObject
|
||||
Actions map[int]FieldActionObject
|
||||
Equipment *EquipmentGachaCatalog
|
||||
RewardGraph *RewardGraph
|
||||
}
|
||||
type FieldRewardObject struct {
|
||||
ID, MapID, GroupID, Type, ResetType, QuestID, BuffID, MonsterID int
|
||||
Rewards []BattleReward
|
||||
Ratios []uint64
|
||||
DropCount, DropType uint64
|
||||
}
|
||||
|
||||
func LoadFieldObjects(root, version string, pack int) (FieldObjectDesign, error) {
|
||||
db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", pack))
|
||||
if err != nil {
|
||||
return FieldObjectDesign{}, err
|
||||
}
|
||||
defer release()
|
||||
common, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return FieldObjectDesign{}, err
|
||||
}
|
||||
defer closeDB()
|
||||
return loadFieldObjects(db, common)
|
||||
}
|
||||
func loadFieldObjects(db, common *sql.DB) (FieldObjectDesign, error) {
|
||||
design := FieldObjectDesign{Objects: map[int]FieldRewardObject{}, Equipment: &EquipmentGachaCatalog{equipment: map[uint64]EquipmentDesign{}}}
|
||||
graph, err := loadRewardGraph(common)
|
||||
if err != nil {
|
||||
return design, err
|
||||
}
|
||||
design.RewardGraph = graph
|
||||
groups := map[int]FieldRewardObject{}
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM FieldRewardObjectGroupTable")
|
||||
if err != nil {
|
||||
return design, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var raw []byte
|
||||
if err = rows.Scan(&id, &raw); err != nil {
|
||||
_ = rows.Close()
|
||||
return design, err
|
||||
}
|
||||
obj := FieldRewardObject{GroupID: id}
|
||||
for _, field := range []int{2, 3} {
|
||||
v, e := packedInts(raw, field)
|
||||
if e != nil || len(v) > 0 {
|
||||
_ = rows.Close()
|
||||
return design, fmt.Errorf("gamedata: field object random buff graph %d requires authored selection rules", id)
|
||||
}
|
||||
}
|
||||
for f, target := range map[int]*int{1: &obj.BuffID, 7: &obj.MonsterID, 8: &obj.QuestID, 9: &obj.ResetType, 12: &obj.Type} {
|
||||
v, e := packedInts(raw, f)
|
||||
if e != nil || len(v) > 1 {
|
||||
_ = rows.Close()
|
||||
return design, fmt.Errorf("gamedata: malformed field object group %d", id)
|
||||
}
|
||||
if len(v) > 0 {
|
||||
*target = int(v[0])
|
||||
}
|
||||
}
|
||||
group, e := packedInts(raw, 10)
|
||||
if e != nil || len(group) > 1 {
|
||||
_ = rows.Close()
|
||||
return design, fmt.Errorf("gamedata: malformed field reward group %d", id)
|
||||
}
|
||||
if len(group) == 1 {
|
||||
var rewardRaw []byte
|
||||
if err = common.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", group[0]).Scan(&rewardRaw); err != nil {
|
||||
_ = rows.Close()
|
||||
return design, err
|
||||
}
|
||||
ids, e1 := packedInts(rewardRaw, 5)
|
||||
types, e2 := packedInts(rewardRaw, 6)
|
||||
counts, e3 := packedInts(rewardRaw, 4)
|
||||
drop, e4 := packedInts(rewardRaw, 1)
|
||||
ratios, e5 := packedInts(rewardRaw, 8)
|
||||
dropType, e6 := packedInts(rewardRaw, 2)
|
||||
if e1 != nil || e2 != nil || e3 != nil || e4 != nil || e5 != nil || e6 != nil {
|
||||
_ = rows.Close()
|
||||
return design, fmt.Errorf("gamedata: malformed field loot %d", group[0])
|
||||
}
|
||||
if len(ids) > 0 && len(ids) == len(types) && len(ids) == len(counts) && len(ratios) == len(ids) && len(drop) <= 1 && len(dropType) <= 1 {
|
||||
if len(drop) == 1 {
|
||||
obj.DropCount = drop[0]
|
||||
}
|
||||
obj.Ratios = ratios
|
||||
if len(dropType) > 0 {
|
||||
obj.DropType = dropType[0]
|
||||
}
|
||||
for i := range ids {
|
||||
obj.Rewards = append(obj.Rewards, BattleReward{ID: ids[i], Type: types[i], Count: counts[i]})
|
||||
}
|
||||
if e := design.validateLoot(common, obj.Rewards); e != nil {
|
||||
_ = rows.Close()
|
||||
return design, e
|
||||
}
|
||||
} else {
|
||||
_ = rows.Close()
|
||||
return design, fmt.Errorf("gamedata: malformed field loot entries %d", group[0])
|
||||
}
|
||||
}
|
||||
groups[id] = obj
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return design, err
|
||||
}
|
||||
_ = rows.Close()
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM FieldRewardObjectTable")
|
||||
if err != nil {
|
||||
return design, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var raw []byte
|
||||
if err = rows.Scan(&id, &raw); err != nil {
|
||||
return design, err
|
||||
}
|
||||
group, e1 := packedInts(raw, 3)
|
||||
maps, e2 := packedInts(raw, 6)
|
||||
if e1 != nil || e2 != nil || len(group) != 1 || len(maps) > 1 || id <= 0 {
|
||||
return design, fmt.Errorf("gamedata: malformed field object %d", id)
|
||||
}
|
||||
obj, ok := groups[int(group[0])]
|
||||
if !ok {
|
||||
return design, fmt.Errorf("gamedata: missing object group %d", group[0])
|
||||
}
|
||||
obj.ID = id
|
||||
if len(maps) > 0 {
|
||||
obj.MapID = int(maps[0])
|
||||
}
|
||||
design.Objects[id] = obj
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return design, err
|
||||
}
|
||||
_ = rows.Close()
|
||||
design.Actions, err = loadFieldActionObjects(db)
|
||||
return design, err
|
||||
}
|
||||
|
||||
// DropType 0 makes DropCount weighted selections. DropType 1 checks each
|
||||
// component's percentage independently; its DropCount may be proto default zero.
|
||||
func (o FieldRewardObject) Draw() ([]BattleReward, error) {
|
||||
return o.draw(cryptoDraw)
|
||||
}
|
||||
|
||||
// Validate every OPEN branch before the catalog is installed, including zero
|
||||
// weight leaves. DIRECT boxes keep their inventory identity and need no roll.
|
||||
func (d FieldObjectDesign) validateLoot(db *sql.DB, rewards []BattleReward) error {
|
||||
visiting := map[uint64]bool{}
|
||||
budget := 100000
|
||||
var visit func(BattleReward) error
|
||||
visit = func(r BattleReward) error {
|
||||
budget--
|
||||
if budget < 0 || r.Count == 0 || r.Count > uint64(^uint32(0)>>1) {
|
||||
return fmt.Errorf("gamedata: invalid field loot quantity/budget")
|
||||
}
|
||||
switch r.Type {
|
||||
case 2, 3, 4, 12, 20:
|
||||
return nil
|
||||
case 5, 7, 8, 13, 14, 17, 19, 27, 29:
|
||||
if r.ID == 0 {
|
||||
return fmt.Errorf("gamedata: invalid field loot item")
|
||||
}
|
||||
return nil
|
||||
case 10:
|
||||
if r.ID == 0 || r.Count > 100 {
|
||||
return fmt.Errorf("gamedata: invalid field loot equipment")
|
||||
}
|
||||
return d.Equipment.loadEquipmentTree(db, WeightedEquipment{ID: r.ID})
|
||||
case 9:
|
||||
g := d.RewardGraph
|
||||
gid, exists := g.boxes[r.ID]
|
||||
if !exists || r.ID == 0 {
|
||||
return fmt.Errorf("gamedata: missing field loot box %d", r.ID)
|
||||
}
|
||||
if g.direct[r.ID] {
|
||||
return nil
|
||||
}
|
||||
if visiting[r.ID] || len(visiting) >= 32 {
|
||||
return fmt.Errorf("gamedata: cyclic field loot box %d", r.ID)
|
||||
}
|
||||
raw, exists := g.groups[gid]
|
||||
if !exists {
|
||||
return fmt.Errorf("gamedata: missing field loot group %d", gid)
|
||||
}
|
||||
children, e := eventGameRewards(raw, 6, 5, 4)
|
||||
weights, e2 := packedInts(raw, 8)
|
||||
drop, e3 := optionalScalar(raw, 2)
|
||||
count, e4 := optionalScalar(raw, 1)
|
||||
if e != nil || e2 != nil || e3 != nil || e4 != nil || len(children) == 0 || len(children) != len(weights) || drop > 1 || drop == 0 && (count == 0 || count > 100) {
|
||||
return fmt.Errorf("gamedata: malformed field loot group %d", gid)
|
||||
}
|
||||
var total uint64
|
||||
visiting[r.ID] = true
|
||||
defer delete(visiting, r.ID)
|
||||
for i, child := range children {
|
||||
if ^uint64(0)-total < weights[i] || drop == 1 && weights[i] > 100 {
|
||||
return fmt.Errorf("gamedata: invalid field loot ratios %d", gid)
|
||||
}
|
||||
total += weights[i]
|
||||
if e := visit(child); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
if drop == 0 && total == 0 {
|
||||
return fmt.Errorf("gamedata: empty field loot distribution %d", gid)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("gamedata: unsupported field loot type %d", r.Type)
|
||||
}
|
||||
}
|
||||
for _, r := range rewards {
|
||||
if err := visit(r); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o FieldRewardObject) draw(draw func(uint64) (uint64, error)) ([]BattleReward, error) {
|
||||
if o.DropType > 1 || (o.DropType == 0 && o.DropCount == 0) || o.DropCount > 100 || len(o.Rewards) == 0 || len(o.Rewards) != len(o.Ratios) {
|
||||
return nil, fmt.Errorf("gamedata: unsupported field reward draw")
|
||||
}
|
||||
var total uint64
|
||||
for i, r := range o.Ratios {
|
||||
if o.Rewards[i].Count == 0 || o.Rewards[i].Count > uint64(^uint32(0)>>1) || (o.DropType == 1 && r > 100) {
|
||||
return nil, fmt.Errorf("gamedata: malformed field reward component")
|
||||
}
|
||||
if ^uint64(0)-total < r {
|
||||
return nil, fmt.Errorf("gamedata: reward ratio overflow")
|
||||
}
|
||||
total += r
|
||||
}
|
||||
if total == 0 && o.DropType == 0 {
|
||||
return nil, fmt.Errorf("gamedata: empty field reward distribution")
|
||||
}
|
||||
var result []BattleReward
|
||||
if o.DropType == 1 {
|
||||
for i, ratio := range o.Ratios {
|
||||
if ratio == 0 {
|
||||
continue
|
||||
}
|
||||
if ratio < 100 {
|
||||
value, err := draw(100)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value >= 100 {
|
||||
return nil, fmt.Errorf("gamedata: field reward random out of range")
|
||||
}
|
||||
if value >= ratio {
|
||||
continue
|
||||
}
|
||||
}
|
||||
result = append(result, o.Rewards[i])
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
for n := uint64(0); n < o.DropCount; n++ {
|
||||
value, err := draw(total)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value >= total {
|
||||
return nil, fmt.Errorf("gamedata: field reward random out of range")
|
||||
}
|
||||
pick := value
|
||||
for i, ratio := range o.Ratios {
|
||||
if pick < ratio {
|
||||
result = append(result, o.Rewards[i])
|
||||
break
|
||||
}
|
||||
pick -= ratio
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// FieldPack describes non-story arena fields. PackType comes from the protocol
|
||||
// enum; IDs and entry restrictions come from the installed GameData tables.
|
||||
type FieldPack struct {
|
||||
ID, Type, BuyPrice, UseSchedule uint64
|
||||
TicketID, SquadLevel uint64
|
||||
HasOpenRule bool
|
||||
MapIDs map[int]bool
|
||||
}
|
||||
|
||||
func LoadFieldPacks(root, version string) (map[int]FieldPack, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
return loadFieldPacks(db)
|
||||
}
|
||||
|
||||
func loadFieldPacks(db *sql.DB) (map[int]FieldPack, error) {
|
||||
result := map[int]FieldPack{}
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM PackTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
values := map[int]uint64{}
|
||||
for _, field := range []int{7, 25, 55, 65} {
|
||||
v, e := packedInts(raw, field)
|
||||
if e != nil || len(v) > 1 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid arena pack %d field %d", id, field)
|
||||
}
|
||||
if len(v) == 1 {
|
||||
values[field] = v[0]
|
||||
}
|
||||
}
|
||||
if values[55] != 3 && values[55] != 10 {
|
||||
continue
|
||||
}
|
||||
if id <= 0 || values[25] != uint64(id) {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid field pack identity %d", id)
|
||||
}
|
||||
result[id] = FieldPack{ID: uint64(id), Type: values[55], BuyPrice: values[7], UseSchedule: values[65], MapIDs: map[int]bool{}}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM ContentOpenTable WHERE groupId=1 ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
pack, exists := result[id]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
pack.HasOpenRule = true
|
||||
for field, target := range map[int]*uint64{5: &pack.SquadLevel, 6: &pack.TicketID} {
|
||||
v, e := packedInts(raw, field)
|
||||
if e != nil || len(v) > 1 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid field pack opening %d", id)
|
||||
}
|
||||
if len(v) == 1 {
|
||||
*target = v[0]
|
||||
}
|
||||
}
|
||||
result[id] = pack
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT id,packId FROM MapTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id, packID int
|
||||
if err := rows.Scan(&id, &packID); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if pack, exists := result[packID]; exists {
|
||||
pack.MapIDs[id] = true
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for id, pack := range result {
|
||||
if len(pack.MapIDs) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: arena pack %d has no map", id)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/protocol/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 func() { _ = 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 func() { _ = 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 func() { _ = 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,78 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/protocol/wire"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FieldResetSchedule struct {
|
||||
DailyReset time.Duration
|
||||
WeeklyDay time.Weekday
|
||||
}
|
||||
|
||||
func LoadFieldResetSchedule(root, version string) (FieldResetSchedule, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return FieldResetSchedule{}, err
|
||||
}
|
||||
defer closeDB()
|
||||
var raw []byte
|
||||
if err = db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&raw); err != nil {
|
||||
return FieldResetSchedule{}, err
|
||||
}
|
||||
times, found, err := wire.Bytes(raw, 23)
|
||||
if err != nil || !found {
|
||||
return FieldResetSchedule{}, fmt.Errorf("gamedata: missing field daily reset time")
|
||||
}
|
||||
parts := strings.Split(string(times), ":")
|
||||
if len(parts) != 3 {
|
||||
return FieldResetSchedule{}, fmt.Errorf("gamedata: invalid daily reset time")
|
||||
}
|
||||
values := [3]int{}
|
||||
for i, p := range parts {
|
||||
values[i], err = strconv.Atoi(p)
|
||||
if err != nil || values[i] < 0 || (i == 0 && values[i] > 23) || (i > 0 && values[i] > 59) {
|
||||
return FieldResetSchedule{}, fmt.Errorf("gamedata: invalid daily reset time")
|
||||
}
|
||||
}
|
||||
day, _, err := wire.Varint(raw, 97)
|
||||
if err != nil || day > 6 {
|
||||
return FieldResetSchedule{}, fmt.Errorf("gamedata: invalid weekly reset day")
|
||||
}
|
||||
return FieldResetSchedule{DailyReset: time.Duration(values[0])*time.Hour + time.Duration(values[1])*time.Minute + time.Duration(values[2])*time.Second, WeeklyDay: time.Weekday(day)}, nil
|
||||
}
|
||||
|
||||
// Period follows client TimerManager's UTC+9 conversion and GameDefault reset
|
||||
// settings. ResetByEvent needs the event's own lifecycle, never a daily fallback.
|
||||
func (s FieldResetSchedule) Period(reset int, now time.Time) (string, error) {
|
||||
if reset == 1 {
|
||||
return "once", nil
|
||||
}
|
||||
if reset != 0 && reset != 3 {
|
||||
return "", fmt.Errorf("gamedata: missing field event reset lifecycle")
|
||||
}
|
||||
shifted := now.UTC().Add(9*time.Hour - s.DailyReset)
|
||||
if reset == 3 {
|
||||
days := (int(shifted.Weekday()) - int(s.WeeklyDay) + 7) % 7
|
||||
shifted = shifted.AddDate(0, 0, -days)
|
||||
}
|
||||
return shifted.Format("2006-01-02"), nil
|
||||
}
|
||||
|
||||
func (s FieldResetSchedule) Next(reset int, now time.Time) (time.Time, error) {
|
||||
if reset != 0 && reset != 3 {
|
||||
return time.Time{}, fmt.Errorf("gamedata: field reset has no calendar deadline")
|
||||
}
|
||||
shifted := now.UTC().Add(9*time.Hour - s.DailyReset)
|
||||
start := time.Date(shifted.Year(), shifted.Month(), shifted.Day(), 0, 0, 0, 0, time.UTC)
|
||||
if reset == 3 {
|
||||
days := (int(shifted.Weekday()) - int(s.WeeklyDay) + 7) % 7
|
||||
start = start.AddDate(0, 0, 7-days)
|
||||
} else {
|
||||
start = start.AddDate(0, 0, 1)
|
||||
}
|
||||
return start.Add(s.DailyReset - 9*time.Hour), nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package gamedata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type FieldSettingsDesign struct {
|
||||
TalentSlots int
|
||||
CharacterTalentClass map[uint64]uint64
|
||||
CharacterTemporaryPack map[uint64]int
|
||||
}
|
||||
|
||||
func LoadFieldSettingsDesign(root, version string) (*FieldSettingsDesign, error) {
|
||||
db, done, e := openStatDatabase(root, version)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer done()
|
||||
var raw []byte
|
||||
if e = db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&raw); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
n, e := optionalScalar(raw, 109)
|
||||
if e != nil || n == 0 || n > 2147483647 {
|
||||
return nil, fmt.Errorf("gamedata: invalid talent slot capacity")
|
||||
}
|
||||
d := &FieldSettingsDesign{TalentSlots: int(n), CharacterTalentClass: map[uint64]uint64{}, CharacterTemporaryPack: map[uint64]int{}}
|
||||
classes := map[uint64]uint64{}
|
||||
rows, e := db.Query("SELECT id,ProtoBuf FROM TalentTable")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
if e = rows.Scan(&id, &raw); e != nil {
|
||||
_ = rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
if id == 0 || id > 2147483647 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid talent identity")
|
||||
}
|
||||
classes[id], e = optionalScalar(raw, 4)
|
||||
if e != nil {
|
||||
_ = rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
if e = rows.Err(); e != nil {
|
||||
_ = rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
_ = rows.Close()
|
||||
rows, e = db.Query("SELECT id,ProtoBuf FROM CharTable")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
if e = rows.Scan(&id, &raw); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if id == 0 || id > 2147483647 {
|
||||
return nil, fmt.Errorf("gamedata: invalid character identity")
|
||||
}
|
||||
talent, e := optionalScalar(raw, 18)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if class, ok := classes[talent]; ok {
|
||||
d.CharacterTalentClass[id] = class
|
||||
}
|
||||
pack, e := optionalScalar(raw, 21)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if pack > 2147483647 {
|
||||
return nil, fmt.Errorf("gamedata: temporary pack overflows protocol")
|
||||
}
|
||||
if pack != 0 {
|
||||
d.CharacterTemporaryPack[id] = int(pack)
|
||||
}
|
||||
}
|
||||
return d, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type FieldTrap struct {
|
||||
ID, MapID, SwitchID, ResetType, Type int
|
||||
FieldBuff, CoolSeconds, RegenSeconds uint64
|
||||
DefaultEnabled bool
|
||||
QuestRange []uint64
|
||||
Maps []int
|
||||
}
|
||||
type FieldTrapTrigger struct {
|
||||
ID, Type, AutoInteraction int
|
||||
QuestRange []uint64
|
||||
Maps []int
|
||||
}
|
||||
type FieldTrapSwitch struct {
|
||||
ID, ObjectType, OrderType int
|
||||
Objects []uint64
|
||||
}
|
||||
type FieldTrapDesign struct {
|
||||
Traps map[int]FieldTrap
|
||||
Triggers map[int]FieldTrapTrigger
|
||||
Switches map[int]FieldTrapSwitch
|
||||
MapIDs []int
|
||||
StoryModeImmune bool
|
||||
}
|
||||
|
||||
func LoadFieldTraps(root, version string, pack int) (FieldTrapDesign, error) {
|
||||
d := FieldTrapDesign{Traps: map[int]FieldTrap{}, Triggers: map[int]FieldTrapTrigger{}, Switches: map[int]FieldTrapSwitch{}}
|
||||
db, done, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", pack))
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
defer done()
|
||||
for _, table := range []string{"FieldTrapTable", "TriggerTable", "FieldObjectSwitchTable"} {
|
||||
rows, e := db.Query("SELECT id,ProtoBuf FROM " + table + " ORDER BY id")
|
||||
if e != nil {
|
||||
return d, e
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var raw []byte
|
||||
if e = rows.Scan(&id, &raw); e != nil {
|
||||
_ = rows.Close()
|
||||
return d, e
|
||||
}
|
||||
values := map[int]uint64{}
|
||||
for _, field := range []int{2, 3, 4, 5, 8, 9, 11, 13, 14, 17, 18} {
|
||||
if table == "TriggerTable" && (field == 2 || field == 4 || field == 13) || table == "FieldObjectSwitchTable" && field == 4 || table == "FieldTrapTable" && field == 11 {
|
||||
continue
|
||||
}
|
||||
values[field], e = optionalScalar(raw, field)
|
||||
if e != nil {
|
||||
_ = rows.Close()
|
||||
return d, e
|
||||
}
|
||||
}
|
||||
switch table {
|
||||
case "FieldTrapTable":
|
||||
q, e := packedInts(raw, 11)
|
||||
if e != nil {
|
||||
_ = rows.Close()
|
||||
return d, e
|
||||
}
|
||||
if values[3] > 1 || values[14] > 1 || values[4] > 0x7fffffff || values[13] > 0x7fffffff {
|
||||
_ = rows.Close()
|
||||
return d, fmt.Errorf("gamedata: invalid trap %d pack %d", id, pack)
|
||||
}
|
||||
d.Traps[id] = FieldTrap{ID: id, MapID: int(values[8]), SwitchID: int(values[17]), ResetType: int(values[14]), Type: int(values[18]), FieldBuff: values[5], CoolSeconds: values[4], RegenSeconds: values[13], DefaultEnabled: values[3] == 1, QuestRange: q}
|
||||
case "TriggerTable":
|
||||
q, e := packedInts(raw, 12)
|
||||
if e != nil {
|
||||
_ = rows.Close()
|
||||
return d, e
|
||||
}
|
||||
d.Triggers[id] = FieldTrapTrigger{ID: id, Type: int(values[11]), AutoInteraction: int(values[9]), QuestRange: q}
|
||||
case "FieldObjectSwitchTable":
|
||||
objects, e := packedInts(raw, 4)
|
||||
if e != nil {
|
||||
_ = rows.Close()
|
||||
return d, e
|
||||
}
|
||||
d.Switches[id] = FieldTrapSwitch{ID: id, ObjectType: int(values[2]), OrderType: int(values[5]), Objects: objects}
|
||||
}
|
||||
}
|
||||
e = rows.Err()
|
||||
_ = rows.Close()
|
||||
if e != nil {
|
||||
return d, e
|
||||
}
|
||||
}
|
||||
common, closeCommon, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
defer closeCommon()
|
||||
var packRaw []byte
|
||||
if err := common.QueryRow("SELECT ProtoBuf FROM PackTable WHERE id=?", pack).Scan(&packRaw); err != nil {
|
||||
return d, err
|
||||
}
|
||||
packType, err := optionalScalar(packRaw, 55)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
// FieldDeckPacket.IsUsingStoryMode includes PackInfo.IsNewFieldSpecPack.
|
||||
d.StoryModeImmune = packType == 0 || packType == 1000 || packType == 1 && pack >= 1010 || packType == 6 && pack >= 2009
|
||||
mapScenes := map[string]int{}
|
||||
rows, err := common.Query("SELECT id,mapScenePath FROM MapTable WHERE packId=? ORDER BY id", 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
|
||||
}
|
||||
mapScenes[scene] = id
|
||||
d.MapIDs = append(d.MapIDs, id)
|
||||
}
|
||||
err = rows.Err()
|
||||
_ = rows.Close()
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
scenes, closeScenes, err := OpenDatabase(root, version, "FieldObjectSceneData")
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
defer closeScenes()
|
||||
rows, err = scenes.Query(fmt.Sprintf("SELECT tableId,sceneName,objectType FROM FieldObjectSceneData%d WHERE objectType IN (7,15,16)", pack))
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var id, kind int
|
||||
var scene string
|
||||
if err = rows.Scan(&id, &scene, &kind); err != nil {
|
||||
return d, err
|
||||
}
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
mapID, found := mapScenes[scene]
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if kind == 15 {
|
||||
trigger, found := d.Triggers[id]
|
||||
if !found {
|
||||
return d, fmt.Errorf("gamedata: missing trigger %d pack %d", id, pack)
|
||||
}
|
||||
if !slices.Contains(trigger.Maps, mapID) {
|
||||
trigger.Maps = append(trigger.Maps, mapID)
|
||||
}
|
||||
d.Triggers[id] = trigger
|
||||
} else {
|
||||
trap, found := d.Traps[id]
|
||||
if !found {
|
||||
return d, fmt.Errorf("gamedata: missing trap %d pack %d", id, pack)
|
||||
}
|
||||
if !slices.Contains(trap.Maps, mapID) {
|
||||
trap.Maps = append(trap.Maps, mapID)
|
||||
}
|
||||
d.Traps[id] = trap
|
||||
}
|
||||
}
|
||||
return d, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// FirstGachaDesign is the free starter reroll referenced by GameDefaultTable.
|
||||
// It is separate from ordinary and cash-product gachas because its reward
|
||||
// program emits both costumes and equipment and has no purchase currency.
|
||||
type FirstGachaDesign struct {
|
||||
Group GachaGroupDesign
|
||||
GachaID uint64
|
||||
Count int
|
||||
RewardGroup *FirstGachaRewardGroup
|
||||
characters map[uint64]CharacterDesign
|
||||
equipment *EquipmentGachaCatalog
|
||||
}
|
||||
|
||||
type FirstGachaReward struct{ Type, ID uint64 }
|
||||
type FirstGachaRewardGroup struct {
|
||||
ID, DropCount, DropType uint64
|
||||
Entries []FirstGachaRewardEntry
|
||||
}
|
||||
type FirstGachaRewardEntry struct {
|
||||
ItemType, ItemID, Count, Weight uint64
|
||||
Group *FirstGachaRewardGroup
|
||||
}
|
||||
|
||||
func (d *FirstGachaDesign) Character(id uint64) (CharacterDesign, bool) {
|
||||
v, ok := d.characters[id]
|
||||
return v, ok
|
||||
}
|
||||
func (d *FirstGachaDesign) EquipmentCatalog() *EquipmentGachaCatalog { return d.equipment }
|
||||
|
||||
// CostumeCatalog supplies character and duplicate-upgrade metadata only. It
|
||||
// deliberately has no purchasable products, so it cannot enable a zero-price
|
||||
// ordinary draw through the normal catalog.
|
||||
func (d *FirstGachaDesign) CostumeCatalog() *RegularGachaCatalog {
|
||||
return &RegularGachaCatalog{characters: d.characters}
|
||||
}
|
||||
|
||||
func NewFirstGachaDesign(group GachaGroupDesign, id uint64, count int, program *FirstGachaRewardGroup, characters map[uint64]CharacterDesign, equipment map[uint64]EquipmentDesign) (*FirstGachaDesign, error) {
|
||||
if group.ID == 0 || group.GachaSubType != 3 || group.GachaType != 0 || group.TenTimeGachaID != id || id == 0 || count <= 0 || count > 100 {
|
||||
return nil, errors.New("gamedata: invalid first gacha identity")
|
||||
}
|
||||
n, err := firstGachaRewardCount(program, map[*FirstGachaRewardGroup]bool{})
|
||||
if err != nil || n != uint64(count) {
|
||||
return nil, fmt.Errorf("gamedata: first gacha reward count=%d want=%d: %v", n, count, err)
|
||||
}
|
||||
d := &FirstGachaDesign{Group: group, GachaID: id, Count: count, RewardGroup: program, characters: map[uint64]CharacterDesign{}, equipment: &EquipmentGachaCatalog{equipment: map[uint64]EquipmentDesign{}}}
|
||||
var visit func(*FirstGachaRewardGroup) error
|
||||
visit = func(g *FirstGachaRewardGroup) error {
|
||||
for _, e := range g.Entries {
|
||||
switch e.ItemType {
|
||||
case 9:
|
||||
if err := visit(e.Group); err != nil {
|
||||
return err
|
||||
}
|
||||
case 11:
|
||||
c, ok := characters[e.ItemID]
|
||||
if !ok || c.ID == 0 || c.HP == 0 {
|
||||
return fmt.Errorf("gamedata: first gacha costume %d has no character", e.ItemID)
|
||||
}
|
||||
d.characters[e.ItemID] = c
|
||||
case 10:
|
||||
v, ok := equipment[e.ItemID]
|
||||
if !ok || v.ID != e.ItemID || v.Grade == 0 {
|
||||
return fmt.Errorf("gamedata: first gacha equipment %d has no design", e.ItemID)
|
||||
}
|
||||
d.equipment.equipment[e.ItemID] = v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := visit(program); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func LoadFirstGacha(root, version string) (*FirstGachaDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
return loadFirstGacha(db)
|
||||
}
|
||||
|
||||
func loadFirstGacha(db *sql.DB) (*FirstGachaDesign, error) {
|
||||
read := func(table string, id uint64) ([]byte, error) {
|
||||
var b []byte
|
||||
err := db.QueryRow("SELECT ProtoBuf FROM "+table+" WHERE id=?", id).Scan(&b)
|
||||
return b, err
|
||||
}
|
||||
defaults, err := read("GameDefaultTable", 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
first, err := packedInts(defaults, 46)
|
||||
if err != nil || len(first) != 1 || first[0] == 0 {
|
||||
return nil, errors.New("gamedata: missing FirstLimitGachaId")
|
||||
}
|
||||
raw, err := read("GachaGroupTable", first[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value := func(b []byte, n int) (uint64, error) {
|
||||
v, err := packedInts(b, n)
|
||||
if err != nil || len(v) > 1 {
|
||||
return 0, fmt.Errorf("gamedata: malformed first gacha field %d", n)
|
||||
}
|
||||
if len(v) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return v[0], nil
|
||||
}
|
||||
id, err := value(raw, 18)
|
||||
if err != nil || id != first[0] {
|
||||
return nil, errors.New("gamedata: first gacha group identity mismatch")
|
||||
}
|
||||
for field, want := range map[int]uint64{16: 3, 17: 0, 28: 0, 3: 0, 4: 0, 5: 0, 24: 0} {
|
||||
v, e := value(raw, field)
|
||||
if e != nil || v != want {
|
||||
return nil, fmt.Errorf("gamedata: first gacha group %d has unsupported field %d=%d", id, field, v)
|
||||
}
|
||||
}
|
||||
gachaID, err := value(raw, 33)
|
||||
if err != nil || gachaID == 0 {
|
||||
return nil, errors.New("gamedata: first gacha product missing")
|
||||
}
|
||||
product, err := read("GachaTable", gachaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for field, want := range map[int]uint64{9: gachaID, 1: 0, 2: 0, 3: 0, 4: 0, 10: 0, 11: 0, 12: 0} {
|
||||
v, e := value(product, field)
|
||||
if e != nil || v != want {
|
||||
return nil, fmt.Errorf("gamedata: first gacha product has unsupported field %d=%d", field, v)
|
||||
}
|
||||
}
|
||||
tickets, err := packedInts(product, 8)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, id := range tickets {
|
||||
if id != 0 {
|
||||
return nil, errors.New("gamedata: first gacha cannot consume tickets")
|
||||
}
|
||||
}
|
||||
count, err := value(product, 5)
|
||||
if err != nil || count == 0 || count > 100 {
|
||||
return nil, errors.New("gamedata: first gacha count invalid")
|
||||
}
|
||||
rewardID, err := value(product, 7)
|
||||
if err != nil || rewardID == 0 {
|
||||
return nil, errors.New("gamedata: first gacha reward missing")
|
||||
}
|
||||
program, err := loadFirstGachaRewardGroup(db, rewardID, map[uint64]bool{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
characters := map[uint64]CharacterDesign{}
|
||||
equipment := map[uint64]EquipmentDesign{}
|
||||
var load func(*FirstGachaRewardGroup) error
|
||||
load = func(g *FirstGachaRewardGroup) error {
|
||||
for _, e := range g.Entries {
|
||||
switch e.ItemType {
|
||||
case 9:
|
||||
if err := load(e.Group); err != nil {
|
||||
return err
|
||||
}
|
||||
case 11:
|
||||
if _, ok := characters[e.ItemID]; !ok {
|
||||
v, err := loadGachaCharacterDesign(db, e.ItemID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
characters[e.ItemID] = v
|
||||
}
|
||||
case 10:
|
||||
if _, ok := equipment[e.ItemID]; !ok {
|
||||
v, err := loadEquipmentDesign(db, e.ItemID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
equipment[e.ItemID] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := load(program); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
points, err := value(raw, 27)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
group := GachaGroupDesign{ID: id, GachaSubType: 3, TenTimeGachaID: gachaID, PointCount: points}
|
||||
return NewFirstGachaDesign(group, gachaID, int(count), program, characters, equipment)
|
||||
}
|
||||
|
||||
func loadFirstGachaRewardGroup(db *sql.DB, id uint64, visiting map[uint64]bool) (*FirstGachaRewardGroup, error) {
|
||||
if visiting[id] || len(visiting) > 32 {
|
||||
return nil, fmt.Errorf("gamedata: first gacha reward cycle/depth at %d", id)
|
||||
}
|
||||
visiting[id] = true
|
||||
defer delete(visiting, id)
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", id).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields := map[int][]uint64{}
|
||||
for _, n := range []int{1, 2, 4, 5, 6, 8} {
|
||||
v, err := packedInts(raw, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields[n] = v
|
||||
}
|
||||
if len(fields[1]) != 1 || len(fields[2]) > 1 {
|
||||
return nil, errors.New("gamedata: malformed first reward execution")
|
||||
}
|
||||
g := &FirstGachaRewardGroup{ID: id, DropCount: fields[1][0]}
|
||||
if len(fields[2]) == 1 {
|
||||
g.DropType = fields[2][0]
|
||||
}
|
||||
ids := fields[5]
|
||||
if len(ids) == 0 || len(ids) != len(fields[4]) || len(ids) != len(fields[6]) || len(ids) != len(fields[8]) {
|
||||
return nil, errors.New("gamedata: malformed first reward entries")
|
||||
}
|
||||
for i, id := range ids {
|
||||
e := FirstGachaRewardEntry{ItemID: id, ItemType: fields[6][i], Count: fields[4][i], Weight: fields[8][i]}
|
||||
if e.ItemType == 9 {
|
||||
child, err := loadFirstGachaRewardGroup(db, id, visiting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.Group = child
|
||||
}
|
||||
g.Entries = append(g.Entries, e)
|
||||
}
|
||||
if _, err := firstGachaRewardCount(g, map[*FirstGachaRewardGroup]bool{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func firstGachaRewardCount(g *FirstGachaRewardGroup, visiting map[*FirstGachaRewardGroup]bool) (uint64, error) {
|
||||
if g == nil || g.ID == 0 || g.DropCount == 0 || g.DropCount > 100 || len(g.Entries) == 0 || g.DropType > 1 || visiting[g] || len(visiting) > 32 {
|
||||
return 0, errors.New("gamedata: invalid first reward program")
|
||||
}
|
||||
visiting[g] = true
|
||||
defer delete(visiting, g)
|
||||
var total, each, weight uint64
|
||||
for _, e := range g.Entries {
|
||||
if e.ItemID == 0 || e.Count == 0 || e.Count > 100 || e.Weight == 0 || math.MaxUint64-weight < e.Weight {
|
||||
return 0, errors.New("gamedata: invalid first reward entry")
|
||||
}
|
||||
weight += e.Weight
|
||||
n := e.Count
|
||||
switch e.ItemType {
|
||||
case 9:
|
||||
if e.Group == nil || e.Group.ID != e.ItemID {
|
||||
return 0, errors.New("gamedata: first reward child mismatch")
|
||||
}
|
||||
child, err := firstGachaRewardCount(e.Group, visiting)
|
||||
if err != nil || child > 100/e.Count {
|
||||
return 0, errors.New("gamedata: invalid first reward child count")
|
||||
}
|
||||
n = child * e.Count
|
||||
case 10, 11:
|
||||
if e.Group != nil {
|
||||
return 0, errors.New("gamedata: direct first reward has a child")
|
||||
}
|
||||
default:
|
||||
return 0, fmt.Errorf("gamedata: unsupported first reward type %d", e.ItemType)
|
||||
}
|
||||
if g.DropType == 0 {
|
||||
if each == 0 {
|
||||
each = n
|
||||
} else if each != n {
|
||||
return 0, errors.New("gamedata: weighted first reward cardinality differs")
|
||||
}
|
||||
} else {
|
||||
total += n
|
||||
}
|
||||
}
|
||||
if g.DropType == 0 {
|
||||
total = each * g.DropCount
|
||||
}
|
||||
if total > 100 {
|
||||
return 0, errors.New("gamedata: first reward count exceeds bound")
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (d *FirstGachaDesign) Roll() ([]FirstGachaReward, error) { return d.roll(cryptoDraw) }
|
||||
func (d *FirstGachaDesign) roll(draw func(uint64) (uint64, error)) ([]FirstGachaReward, error) {
|
||||
if d == nil {
|
||||
return nil, errors.New("gamedata: missing first gacha")
|
||||
}
|
||||
if n, err := firstGachaRewardCount(d.RewardGroup, map[*FirstGachaRewardGroup]bool{}); err != nil || n != uint64(d.Count) {
|
||||
return nil, errors.New("gamedata: first reward program changed")
|
||||
}
|
||||
var out []FirstGachaReward
|
||||
var run func(*FirstGachaRewardGroup) error
|
||||
emit := func(e FirstGachaRewardEntry) error {
|
||||
for n := uint64(0); n < e.Count; n++ {
|
||||
if e.ItemType == 9 {
|
||||
if err := run(e.Group); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
out = append(out, FirstGachaReward{Type: e.ItemType, ID: e.ItemID})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
run = func(g *FirstGachaRewardGroup) error {
|
||||
if g.DropType == 1 {
|
||||
for _, e := range g.Entries {
|
||||
if err := emit(e); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var sum uint64
|
||||
for _, e := range g.Entries {
|
||||
sum += e.Weight
|
||||
}
|
||||
for n := uint64(0); n < g.DropCount; n++ {
|
||||
v, err := draw(sum)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v >= sum {
|
||||
return errors.New("gamedata: first reward random out of bounds")
|
||||
}
|
||||
for _, e := range g.Entries {
|
||||
if v < e.Weight {
|
||||
if err := emit(e); err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
v -= e.Weight
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := run(d.RewardGroup); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) != d.Count {
|
||||
return nil, errors.New("gamedata: first reward count mismatch")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// Food is decoded from FoodTable, including materials so that the server can
|
||||
// reject them explicitly. FavoritePoint replaces Point for matching characters.
|
||||
type Food struct {
|
||||
ID, Type, Point, RecoveryType, FavoritePoint, FoodBuffID uint64
|
||||
FavoriteUniqueCharIDs []uint64
|
||||
}
|
||||
|
||||
type FoodDesign struct{ Foods map[uint64]Food }
|
||||
|
||||
func LoadFoodDesign(root, version string) (*FoodDesign, error) {
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
return loadFoodDesign(db)
|
||||
}
|
||||
|
||||
func loadFoodDesign(db *sql.DB) (*FoodDesign, error) {
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM FoodTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
design := &FoodDesign{Foods: map[uint64]Food{}}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
read := func(field int) (uint64, error) {
|
||||
values, err := packedInts(raw, field)
|
||||
if err != nil || len(values) > 1 {
|
||||
return 0, fmt.Errorf("gamedata: invalid food %d field %d", id, field)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if values[0] > math.MaxInt32 {
|
||||
return 0, fmt.Errorf("gamedata: invalid food %d integer", id)
|
||||
}
|
||||
return values[0], nil
|
||||
}
|
||||
food := Food{ID: id}
|
||||
for field, target := range map[int]*uint64{1: &food.FavoritePoint, 3: &food.FoodBuffID, 4: &food.Type, 13: &food.Point, 14: &food.RecoveryType} {
|
||||
if *target, err = read(field); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
protoID, err := read(7)
|
||||
if err != nil || id == 0 || protoID != id || food.Type > 2 || food.RecoveryType > 1 {
|
||||
return nil, fmt.Errorf("gamedata: invalid food row %d", id)
|
||||
}
|
||||
food.FavoriteUniqueCharIDs, err = packedInts(raw, 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, charID := range food.FavoriteUniqueCharIDs {
|
||||
if charID == 0 || charID > math.MaxInt32 {
|
||||
return nil, fmt.Errorf("gamedata: invalid food %d favorite", id)
|
||||
}
|
||||
}
|
||||
if food.Type != 2 && food.Point == 0 && food.FoodBuffID == 0 {
|
||||
return nil, fmt.Errorf("gamedata: edible food %d has no effect", id)
|
||||
}
|
||||
design.Foods[id] = food
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(design.Foods) == 0 {
|
||||
return nil, errors.New("gamedata: empty FoodTable")
|
||||
}
|
||||
return design, nil
|
||||
}
|
||||
|
||||
// Recovery uses the client's float multiplication and Math.Round midpoint to
|
||||
// even semantics once per item, then multiplies by the requested stack count.
|
||||
func (f Food) Recovery(characterID, maximum, count uint64) (uint64, error) {
|
||||
if f.Type > 1 || f.RecoveryType > 1 || f.FoodBuffID != 0 || count == 0 || count > math.MaxInt32 || maximum == 0 {
|
||||
return 0, errors.New("gamedata: food is not a supported recovery dish")
|
||||
}
|
||||
point := f.Point
|
||||
if f.Type == 1 && slices.Contains(f.FavoriteUniqueCharIDs, characterID/10) {
|
||||
point = f.FavoritePoint
|
||||
}
|
||||
if point == 0 {
|
||||
return 0, errors.New("gamedata: food has no recovery effect")
|
||||
}
|
||||
if f.RecoveryType == 1 {
|
||||
value := math.RoundToEven(float64(float32(maximum) * (float32(point) / float32(100))))
|
||||
if value < 0 || value >= float64(math.MaxUint64) {
|
||||
return 0, errors.New("gamedata: food recovery overflow")
|
||||
}
|
||||
point = uint64(value)
|
||||
}
|
||||
if point != 0 && count > math.MaxUint64/point {
|
||||
return 0, errors.New("gamedata: food recovery overflow")
|
||||
}
|
||||
return point * count, nil
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// FriendshipKey identifies a costume's level or counseling session.
|
||||
type FriendshipKey struct{ GroupID, ID uint64 }
|
||||
|
||||
type FriendshipDefaultDesign struct {
|
||||
CorrectEXP, CorrectSelectDialogIndex, IncorrectEXP uint64
|
||||
MaxCounselingAP, MaxCounselingAPByCostume uint64
|
||||
QuickCounselingUnlockCount uint64
|
||||
MaxLevels [3]uint64
|
||||
CounselingRewards []Reward
|
||||
}
|
||||
|
||||
type FriendshipGiftDesign struct {
|
||||
ID, Type, ItemID, GiftType, EXP, FavoriteEXP uint64
|
||||
FavoriteCostumeIDs []uint64
|
||||
}
|
||||
|
||||
// Experience uses the original friendship costume ID, even when the mapped
|
||||
// owned costume ID is different. GiftType 1 is favorite for every costume.
|
||||
func (g FriendshipGiftDesign) Experience(costumeID uint64) uint64 {
|
||||
if g.GiftType == 1 {
|
||||
return g.FavoriteEXP
|
||||
}
|
||||
if slices.Contains(g.FavoriteCostumeIDs, costumeID) {
|
||||
return g.FavoriteEXP
|
||||
}
|
||||
return g.EXP
|
||||
}
|
||||
|
||||
type FriendshipLevelDesign struct {
|
||||
NextEXP uint64
|
||||
Rewards []Reward
|
||||
}
|
||||
|
||||
type FriendshipSessionDesign struct {
|
||||
DialogGroupID, ChoiceCount uint64
|
||||
}
|
||||
|
||||
// FriendshipDesign holds immutable tables, not account progress. Gift keys
|
||||
// are ItemType and item design ID; ItemType Resource differs from its subtype.
|
||||
type FriendshipDesign struct {
|
||||
Default FriendshipDefaultDesign
|
||||
Costumes map[uint64]uint64
|
||||
Gifts map[[2]uint64]FriendshipGiftDesign
|
||||
Levels map[FriendshipKey]FriendshipLevelDesign
|
||||
Sessions map[FriendshipKey]FriendshipSessionDesign
|
||||
}
|
||||
|
||||
// LoadFriendshipDesign decrypts and opens the common database once for all
|
||||
// friendship tables. The query helper is shared with other common designs.
|
||||
func LoadFriendshipDesign(root, version string) (FriendshipDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return FriendshipDesign{}, err
|
||||
}
|
||||
defer closeDB()
|
||||
return loadFriendshipDesign(db)
|
||||
}
|
||||
|
||||
func loadFriendshipDesign(db *sql.DB) (FriendshipDesign, error) {
|
||||
d := FriendshipDesign{Costumes: map[uint64]uint64{}, Gifts: map[[2]uint64]FriendshipGiftDesign{}, Levels: map[FriendshipKey]FriendshipLevelDesign{}, Sessions: map[FriendshipKey]FriendshipSessionDesign{}}
|
||||
if db == nil {
|
||||
return d, fmt.Errorf("gamedata: nil friendship database")
|
||||
}
|
||||
var proto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM FriendshipDefaultTable WHERE id=0").Scan(&proto); err != nil {
|
||||
return d, fmt.Errorf("gamedata: friendship defaults: %w", err)
|
||||
}
|
||||
defaults := []*uint64{&d.Default.CorrectEXP, &d.Default.CorrectSelectDialogIndex, &d.Default.IncorrectEXP, &d.Default.MaxCounselingAP, &d.Default.MaxCounselingAPByCostume, &d.Default.QuickCounselingUnlockCount, &d.Default.MaxLevels[0], &d.Default.MaxLevels[1], &d.Default.MaxLevels[2]}
|
||||
for i, field := range []int{1, 2, 12, 13, 14, 18, 7, 8, 9} {
|
||||
value, err := friendshipScalar(proto, field)
|
||||
if err != nil {
|
||||
return d, fmt.Errorf("gamedata: friendship defaults: %w", err)
|
||||
}
|
||||
*defaults[i] = value
|
||||
}
|
||||
var err error
|
||||
d.Default.CounselingRewards, err = friendshipRewards(proto, 5, 4, 3, true)
|
||||
if err != nil {
|
||||
return d, fmt.Errorf("gamedata: friendship counseling rewards: %w", err)
|
||||
}
|
||||
if d.Default.CorrectEXP == 0 || d.Default.IncorrectEXP == 0 || d.Default.MaxCounselingAP == 0 || d.Default.MaxCounselingAPByCostume == 0 || d.Default.QuickCounselingUnlockCount == 0 || d.Default.MaxLevels[0] <= 1 || d.Default.MaxLevels[0] >= d.Default.MaxLevels[1] || d.Default.MaxLevels[1] >= d.Default.MaxLevels[2] {
|
||||
return d, fmt.Errorf("gamedata: invalid friendship defaults")
|
||||
}
|
||||
if err := friendshipRows(db, "SELECT id,ProtoBuf FROM FriendshipCostumeTable ORDER BY id", func(id uint64, proto []byte) error {
|
||||
protoID, err := friendshipScalar(proto, 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
costumeID, err := friendshipScalar(proto, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if id == 0 || protoID != id || costumeID == 0 {
|
||||
return fmt.Errorf("invalid costume identity %d", id)
|
||||
}
|
||||
d.Costumes[id] = costumeID
|
||||
return nil
|
||||
}); err != nil {
|
||||
return d, fmt.Errorf("gamedata: friendship costumes: %w", err)
|
||||
}
|
||||
if err := friendshipRows(db, "SELECT id,ProtoBuf FROM FriendshipGiftTable ORDER BY id", func(id uint64, proto []byte) error {
|
||||
g := FriendshipGiftDesign{}
|
||||
for i, dest := range []*uint64{&g.ID, &g.Type, &g.ItemID, &g.GiftType, &g.EXP, &g.FavoriteEXP} {
|
||||
value, err := friendshipScalar(proto, []int{5, 7, 6, 4, 1, 2}[i])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*dest = value
|
||||
}
|
||||
var err error
|
||||
g.FavoriteCostumeIDs, err = packedInts(proto, 3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if id == 0 || g.ID != id || g.Type == 0 || g.ItemID == 0 || g.GiftType > 1 || g.FavoriteEXP == 0 || (g.GiftType == 0 && g.EXP == 0) {
|
||||
return fmt.Errorf("invalid gift %d", id)
|
||||
}
|
||||
key := [2]uint64{g.Type, g.ItemID}
|
||||
if _, exists := d.Gifts[key]; exists {
|
||||
return fmt.Errorf("duplicate gift item %v", key)
|
||||
}
|
||||
for _, costumeID := range g.FavoriteCostumeIDs {
|
||||
if _, ok := d.Costumes[costumeID]; !ok {
|
||||
return fmt.Errorf("gift %d references unknown costume %d", id, costumeID)
|
||||
}
|
||||
}
|
||||
d.Gifts[key] = g
|
||||
return nil
|
||||
}); err != nil {
|
||||
return d, fmt.Errorf("gamedata: friendship gifts: %w", err)
|
||||
}
|
||||
if err := friendshipGroupRows(db, "SELECT groupId,id,ProtoBuf FROM FriendshipLevelTable ORDER BY groupId,id", func(key FriendshipKey, proto []byte) error {
|
||||
if err := friendshipIdentity(proto, key); err != nil {
|
||||
return err
|
||||
}
|
||||
next, err := friendshipScalar(proto, 3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rewards, err := friendshipRewards(proto, 6, 5, 4, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := d.Costumes[key.GroupID]; !ok || key.ID == 0 || key.ID > d.Default.MaxLevels[2] || (key.ID < d.Default.MaxLevels[2] && next == 0) {
|
||||
return fmt.Errorf("invalid level %v", key)
|
||||
}
|
||||
d.Levels[key] = FriendshipLevelDesign{NextEXP: next, Rewards: rewards}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return d, fmt.Errorf("gamedata: friendship levels: %w", err)
|
||||
}
|
||||
if err := friendshipGroupRows(db, "SELECT groupId,id,ProtoBuf FROM CounselingSessionTable ORDER BY groupId,id", func(key FriendshipKey, proto []byte) error {
|
||||
if err := friendshipIdentity(proto, key); err != nil {
|
||||
return err
|
||||
}
|
||||
groupID, err := friendshipScalar(proto, 4)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := d.Costumes[key.GroupID]; !ok || key.ID == 0 || groupID == 0 {
|
||||
return fmt.Errorf("invalid session %v", key)
|
||||
}
|
||||
d.Sessions[key] = FriendshipSessionDesign{DialogGroupID: groupID}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return d, fmt.Errorf("gamedata: friendship sessions: %w", err)
|
||||
}
|
||||
// Dialog type 4 is EVisualNovelDialogType.Select. Resolve option counts
|
||||
// from the same design used by TimelineVisualNovelManager.StartSelectTalk.
|
||||
for key, session := range d.Sessions {
|
||||
rows, err := db.Query("SELECT s.ProtoBuf FROM VisualNovelDialogTable v JOIN SelectDialogTable s ON s.id=v.selectDialogId WHERE v.groupId=? AND v.type=4", session.DialogGroupID)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
var matches int
|
||||
for rows.Next() {
|
||||
var selection []byte
|
||||
if err := rows.Scan(&selection); err != nil {
|
||||
_ = rows.Close()
|
||||
return d, err
|
||||
}
|
||||
choices, err := packedInts(selection, 1)
|
||||
if err != nil {
|
||||
_ = rows.Close()
|
||||
return d, err
|
||||
}
|
||||
session.ChoiceCount = uint64(len(choices))
|
||||
matches++
|
||||
}
|
||||
err = rows.Err()
|
||||
_ = rows.Close()
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
if matches != 1 || session.ChoiceCount <= d.Default.CorrectSelectDialogIndex {
|
||||
return d, fmt.Errorf("gamedata: invalid counseling choices %v", key)
|
||||
}
|
||||
d.Sessions[key] = session
|
||||
}
|
||||
for costumeID := range d.Costumes {
|
||||
for level := uint64(1); level <= d.Default.MaxLevels[2]; level++ {
|
||||
if _, ok := d.Levels[FriendshipKey{costumeID, level}]; !ok {
|
||||
return d, fmt.Errorf("gamedata: missing friendship level %d/%d", costumeID, level)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(d.Costumes) == 0 || len(d.Gifts) == 0 || len(d.Sessions) == 0 {
|
||||
return d, fmt.Errorf("gamedata: empty friendship design")
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func friendshipScalar(proto []byte, field int) (uint64, error) {
|
||||
values, err := packedInts(proto, field)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(values) > 1 {
|
||||
return 0, fmt.Errorf("field %d has multiple scalar values", field)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return values[0], nil
|
||||
}
|
||||
|
||||
func friendshipIdentity(proto []byte, key FriendshipKey) error {
|
||||
group, err := friendshipScalar(proto, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := friendshipScalar(proto, 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if group != key.GroupID || id != key.ID {
|
||||
return fmt.Errorf("identity mismatch %v", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func friendshipRewards(proto []byte, typeField, idField, countField int, scalar bool) ([]Reward, error) {
|
||||
types, err := packedInts(proto, typeField)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids, err := packedInts(proto, idField)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts, err := packedInts(proto, countField)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The default reward is scalar, so omitted ID means protobuf's zero ID.
|
||||
if scalar && len(ids) == 0 && len(types) == 1 {
|
||||
ids = []uint64{0}
|
||||
}
|
||||
if len(types) != len(ids) || len(types) != len(counts) || (scalar && len(types) > 1) {
|
||||
return nil, fmt.Errorf("mismatched reward arrays")
|
||||
}
|
||||
rewards := make([]Reward, len(types))
|
||||
for i := range types {
|
||||
if types[i] == 0 || counts[i] == 0 {
|
||||
return nil, fmt.Errorf("invalid reward %d", i)
|
||||
}
|
||||
rewards[i] = Reward{Type: types[i], ID: ids[i], Count: counts[i]}
|
||||
}
|
||||
return rewards, nil
|
||||
}
|
||||
|
||||
func friendshipRows(db *sql.DB, query string, visit func(uint64, []byte) error) error {
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := visit(id, proto); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func friendshipGroupRows(db *sql.DB, query string, visit func(FriendshipKey, []byte) error) error {
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var key FriendshipKey
|
||||
var proto []byte
|
||||
if err := rows.Scan(&key.GroupID, &key.ID, &proto); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := visit(key, proto); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Cash product tickets are the deterministic CashProductTable -> RandomBoxTable
|
||||
// -> RewardGroupTable grant. Their IDs and quantities are version facts.
|
||||
func loadGachaCashRewards(db *sql.DB, group GachaGroupDesign) ([]BattleReward, error) {
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CashProductTable WHERE GroupId=? AND id=? AND saleGroup=?", group.CashProductGroupID, group.CashProductID, group.CashSalesGroup).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
box, err := optionalScalar(raw, 14)
|
||||
if err != nil || box == 0 {
|
||||
return nil, fmt.Errorf("gamedata: gacha cash product has no random box")
|
||||
}
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM RandomBoxTable WHERE id=?", box).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rewardID, err := optionalScalar(raw, 9)
|
||||
if err != nil || rewardID == 0 {
|
||||
return nil, fmt.Errorf("gamedata: gacha cash box has no reward group")
|
||||
}
|
||||
return deterministicGachaCashRewards(db, rewardID, map[uint64]bool{})
|
||||
}
|
||||
|
||||
func deterministicGachaCashRewards(db *sql.DB, id uint64, visiting map[uint64]bool) ([]BattleReward, error) {
|
||||
if visiting[id] {
|
||||
return nil, fmt.Errorf("gamedata: cyclic cash reward group %d", id)
|
||||
}
|
||||
visiting[id] = true
|
||||
defer delete(visiting, id)
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", id).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dropType, err := optionalScalar(raw, 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids, err := packedInts(raw, 5)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
types, err := packedInts(raw, 6)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts, err := packedInts(raw, 4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ratios, err := packedInts(raw, 8)
|
||||
if err != nil || len(ratios) != len(ids) {
|
||||
return nil, fmt.Errorf("gamedata: malformed cash reward ratio")
|
||||
}
|
||||
for _, ratio := range ratios {
|
||||
if dropType == 0 && ratio == 0 || dropType == 1 && ratio != 100 {
|
||||
return nil, fmt.Errorf("gamedata: cash reward is not deterministic")
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 || len(ids) != len(types) || len(ids) != len(counts) || dropType > 1 || dropType == 0 && len(ids) != 1 {
|
||||
return nil, fmt.Errorf("gamedata: cash reward %d is not deterministic", id)
|
||||
}
|
||||
dropCount, err := optionalScalar(raw, 1)
|
||||
if err != nil || dropType == 0 && dropCount == 0 {
|
||||
return nil, fmt.Errorf("gamedata: cash reward has invalid drop count")
|
||||
}
|
||||
if dropType == 0 {
|
||||
for i := range counts {
|
||||
if counts[i] > ^uint64(0)/dropCount {
|
||||
return nil, fmt.Errorf("gamedata: cash reward count overflow")
|
||||
}
|
||||
counts[i] *= dropCount
|
||||
}
|
||||
}
|
||||
var out []BattleReward
|
||||
for i, itemID := range ids {
|
||||
if counts[i] == 0 {
|
||||
return nil, fmt.Errorf("gamedata: cash reward %d has zero count", id)
|
||||
}
|
||||
if types[i] == 9 {
|
||||
children, err := deterministicGachaCashRewards(db, itemID, visiting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, child := range children {
|
||||
if child.Count > ^uint64(0)/counts[i] {
|
||||
return nil, fmt.Errorf("gamedata: cash reward overflow")
|
||||
}
|
||||
child.Count *= counts[i]
|
||||
out = append(out, child)
|
||||
}
|
||||
} else {
|
||||
if (!itemDBInfoRewardType(types[i]) && types[i] != 19) || itemID == 0 {
|
||||
return nil, fmt.Errorf("gamedata: unsupported cash reward type %d", types[i])
|
||||
}
|
||||
out = append(out, BattleReward{Type: types[i], ID: itemID, Count: counts[i]})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Costume ownership refers to a unique character, not a numeric costume ID
|
||||
// prefix. The initial growth grade is the base character, excluding pack-only
|
||||
// temporary copies; ambiguous rows are rejected instead of selecting an ID.
|
||||
func loadCostumeBaseCharacterID(db *sql.DB, costumeID uint64) (uint64, error) {
|
||||
base, _, err := loadCostumeCharacterFamily(db, costumeID)
|
||||
return base, err
|
||||
}
|
||||
|
||||
// The family includes every permanent promotion stage. A costume reward must
|
||||
// reuse its owned character even when promotion has changed CharTable.Id.
|
||||
func loadCostumeCharacterFamily(db *sql.DB, costumeID uint64) (uint64, []uint64, error) {
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CostumeTable WHERE id=?", costumeID).Scan(&raw); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
unique, err := optionalScalar(raw, 27)
|
||||
if err != nil || unique == 0 {
|
||||
return 0, nil, fmt.Errorf("gamedata: costume %d unique character unavailable", costumeID)
|
||||
}
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM CharTable WHERE uniqueCharId=?", unique)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var result uint64
|
||||
var family []uint64
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var row []byte
|
||||
if err := rows.Scan(&id, &row); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
growth, err := optionalScalar(row, 10)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
temporary, err := optionalScalar(row, 21)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
kind, err := optionalScalar(row, 19)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if temporary != 0 || kind != 0 {
|
||||
continue
|
||||
}
|
||||
family = append(family, id)
|
||||
if growth != 1 {
|
||||
continue
|
||||
}
|
||||
if result != 0 {
|
||||
return 0, nil, fmt.Errorf("gamedata: costume %d has ambiguous base character", costumeID)
|
||||
}
|
||||
result = id
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if result == 0 {
|
||||
return 0, nil, fmt.Errorf("gamedata: costume %d has no base character", costumeID)
|
||||
}
|
||||
return result, family, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// gradePool conditions the actual nested weighted distribution on one grade.
|
||||
// Exact rational path weights preserve branches with different child totals.
|
||||
func (g RegularGacha) gradePool(grade uint64) ([]WeightedCostume, error) {
|
||||
weights := map[uint64]*big.Rat{}
|
||||
var walk func([]WeightedCostume, *big.Rat) error
|
||||
walk = func(pool []WeightedCostume, path *big.Rat) error {
|
||||
total := new(big.Int)
|
||||
for _, item := range pool {
|
||||
if item.Weight == 0 {
|
||||
return errors.New("gamedata: zero costume weight")
|
||||
}
|
||||
total.Add(total, new(big.Int).SetUint64(item.Weight))
|
||||
}
|
||||
if total.Sign() == 0 {
|
||||
return errors.New("gamedata: empty costume pool")
|
||||
}
|
||||
for _, item := range pool {
|
||||
prob := new(big.Rat).Mul(path, new(big.Rat).SetFrac(new(big.Int).SetUint64(item.Weight), total))
|
||||
if item.ID == 0 {
|
||||
if err := walk(item.Children, prob); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
actual, ok := g.Grades[item.ID]
|
||||
if !ok || actual < 3 || actual > 5 {
|
||||
return errors.New("gamedata: costume grade unavailable")
|
||||
}
|
||||
if actual == grade {
|
||||
if weights[item.ID] == nil {
|
||||
weights[item.ID] = new(big.Rat)
|
||||
}
|
||||
weights[item.ID].Add(weights[item.ID], prob)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := walk(g.Pool, new(big.Rat).SetInt64(1)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
denom := big.NewInt(1)
|
||||
for _, weight := range weights {
|
||||
gcd := new(big.Int).GCD(nil, nil, denom, weight.Denom())
|
||||
denom.Mul(new(big.Int).Quo(denom, gcd), weight.Denom())
|
||||
}
|
||||
var ids []uint64
|
||||
for id := range weights {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
// Stable table-independent ordering makes deterministic draw sources reproducible.
|
||||
for i := 1; i < len(ids); i++ {
|
||||
for j := i; j > 0 && ids[j] < ids[j-1]; j-- {
|
||||
ids[j], ids[j-1] = ids[j-1], ids[j]
|
||||
}
|
||||
}
|
||||
var out []WeightedCostume
|
||||
for _, id := range ids {
|
||||
w := weights[id]
|
||||
count := new(big.Int).Mul(w.Num(), new(big.Int).Quo(new(big.Int).Set(denom), w.Denom()))
|
||||
if !count.IsUint64() {
|
||||
return nil, errors.New("gamedata: conditioned costume weight overflow")
|
||||
}
|
||||
out = append(out, WeightedCostume{ID: id, Weight: count.Uint64()})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func loadInfiniteGachaIdentity(db *sql.DB, selectedGroups []uint64) (groupID, gachaID uint64, err error) {
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable ORDER BY id")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err = rows.Scan(&id, &raw); err != nil {
|
||||
return
|
||||
}
|
||||
typ, e := optionalScalar(raw, 17)
|
||||
if e != nil {
|
||||
return 0, 0, e
|
||||
}
|
||||
sub, e := optionalScalar(raw, 16)
|
||||
if e != nil {
|
||||
return 0, 0, e
|
||||
}
|
||||
selected := false
|
||||
for _, wanted := range selectedGroups {
|
||||
if wanted == id {
|
||||
selected = true
|
||||
}
|
||||
}
|
||||
if !selected || typ != 1 || sub != 5 {
|
||||
continue
|
||||
}
|
||||
if groupID != 0 {
|
||||
return 0, 0, fmt.Errorf("gamedata: multiple infinite gacha groups require schedule selection")
|
||||
}
|
||||
groupID = id
|
||||
gachaID, err = optionalScalar(raw, 33)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return
|
||||
}
|
||||
if groupID == 0 || gachaID == 0 {
|
||||
err = fmt.Errorf("gamedata: infinite gacha group missing")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func loadInfiniteCashIdentity(db *sql.DB, rewardID uint64) (groupID, productID, saleGroup uint64, err error) {
|
||||
rows, err := db.Query("SELECT ProtoBuf FROM CashProductTable ORDER BY GroupId,id,saleGroup")
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
defer func() { _ = 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
|
||||
}
|
||||
if box == 0 {
|
||||
continue
|
||||
}
|
||||
var boxRaw []byte
|
||||
if e = db.QueryRow("SELECT ProtoBuf FROM RandomBoxTable WHERE id=?", box).Scan(&boxRaw); e != nil {
|
||||
return 0, 0, 0, e
|
||||
}
|
||||
reward, e := optionalScalar(boxRaw, 9)
|
||||
if e != nil {
|
||||
return 0, 0, 0, e
|
||||
}
|
||||
if reward != rewardID {
|
||||
continue
|
||||
}
|
||||
if productID != 0 {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: ambiguous infinite cash product")
|
||||
}
|
||||
groupID, err = optionalScalar(raw, 5)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
productID, err = optionalScalar(raw, 6)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
saleGroup, err = optionalScalar(raw, 15)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return
|
||||
}
|
||||
if groupID == 0 || productID == 0 {
|
||||
err = fmt.Errorf("gamedata: infinite cash product missing")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// GrowthMaterial is a stack of ResourceTable material consumed by CharGrowth.
|
||||
type GrowthMaterial struct{ ID, Count uint64 }
|
||||
|
||||
// CharacterGrowth applies ResourceTable.MagicValue to the character's exact
|
||||
// CharGrowthTable/CharLevelTable curve from the installed GameData.
|
||||
func CharacterGrowth(root, version string, charID int, level, exp uint64, materials []GrowthMaterial) (uint64, uint64, []GrowthMaterial, error) {
|
||||
if charID <= 0 || level == 0 || len(materials) == 0 {
|
||||
return 0, 0, nil, fmt.Errorf("gamedata: invalid character growth input")
|
||||
}
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
var charProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(&charProto); err != nil {
|
||||
return 0, 0, nil, fmt.Errorf("gamedata: character %d: %w", charID, err)
|
||||
}
|
||||
growthIDs, err := packedInts(charProto, 1)
|
||||
if err != nil || len(growthIDs) != 1 {
|
||||
return 0, 0, nil, fmt.Errorf("gamedata: character %d growth ID %v: %w", charID, growthIDs, err)
|
||||
}
|
||||
var growthProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthIDs[0]).Scan(&growthProto); err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
groups, err := packedInts(growthProto, 1)
|
||||
if err != nil || len(groups) != 1 {
|
||||
return 0, 0, nil, fmt.Errorf("gamedata: growth %d group %v: %w", growthIDs[0], groups, err)
|
||||
}
|
||||
maximum, err := packedInts(growthProto, 9)
|
||||
if err != nil || len(maximum) != 1 || maximum[0] < level {
|
||||
return 0, 0, nil, fmt.Errorf("gamedata: growth %d maximum %v: %w", growthIDs[0], maximum, err)
|
||||
}
|
||||
|
||||
var gained uint64
|
||||
for _, material := range materials {
|
||||
if material.ID == 0 || material.Count == 0 {
|
||||
return 0, 0, nil, fmt.Errorf("gamedata: invalid growth material")
|
||||
}
|
||||
var proto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM ResourceTable WHERE id=?", material.ID).Scan(&proto); err != nil {
|
||||
return 0, 0, nil, fmt.Errorf("gamedata: resource %d: %w", material.ID, err)
|
||||
}
|
||||
values, err := packedInts(proto, 9)
|
||||
if err != nil || len(values) != 1 || values[0] == 0 {
|
||||
return 0, 0, nil, fmt.Errorf("gamedata: resource %d magic value %v: %w", material.ID, values, err)
|
||||
}
|
||||
if material.Count > (^uint64(0)-gained)/values[0] {
|
||||
return 0, 0, nil, fmt.Errorf("gamedata: growth experience overflow")
|
||||
}
|
||||
gained += material.Count * values[0]
|
||||
}
|
||||
|
||||
curve := make(map[uint64]uint64)
|
||||
rows, err := db.Query("SELECT ProtoBuf FROM CharLevelTable")
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var proto []byte
|
||||
if err := rows.Scan(&proto); err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
group, _ := packedInts(proto, 5)
|
||||
if len(group) != 1 || group[0] != groups[0] {
|
||||
continue
|
||||
}
|
||||
levels, _ := packedInts(proto, 7)
|
||||
required, _ := packedInts(proto, 8)
|
||||
if len(levels) == 1 && len(required) == 1 {
|
||||
curve[levels[0]] = required[0]
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
currentLevel, currentExp := level, exp
|
||||
for gained > 0 && currentLevel < maximum[0] {
|
||||
required := curve[currentLevel]
|
||||
if required == 0 || currentExp >= required {
|
||||
return 0, 0, nil, fmt.Errorf("gamedata: missing/invalid level curve group %d level %d", groups[0], currentLevel)
|
||||
}
|
||||
need := required - currentExp
|
||||
if gained < need {
|
||||
currentExp += gained
|
||||
gained = 0
|
||||
break
|
||||
}
|
||||
gained -= need
|
||||
currentLevel++
|
||||
currentExp = 0
|
||||
}
|
||||
if currentLevel == maximum[0] {
|
||||
currentExp = 0
|
||||
refunds, err := refundGrowthResources(db, gained)
|
||||
return currentLevel, currentExp, refunds, err
|
||||
}
|
||||
return currentLevel, currentExp, nil, nil
|
||||
}
|
||||
|
||||
// Convert max-level overflow back into the installed experience resources.
|
||||
// Growth refunds use larger denominations greedily and round
|
||||
// the final remainder up to one smallest slime. This can refund slightly
|
||||
// more nominal EXP than the overflow: the verified tutorial sample has 551
|
||||
// overflow EXP and returns resource 7 x3 (600 EXP), not x2.
|
||||
func refundGrowthResources(db *sql.DB, overflow uint64) ([]GrowthMaterial, error) {
|
||||
if overflow == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM ResourceTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
type unit struct{ id, value uint64 }
|
||||
var units []unit
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kind, err := packedInts(proto, 13)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := packedInts(proto, 9)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(kind) == 1 && kind[0] == 1 && len(value) == 1 && value[0] > 0 {
|
||||
units = append(units, unit{id, value[0]})
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(units) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: missing experience resources")
|
||||
}
|
||||
sort.Slice(units, func(i, j int) bool { return units[i].value > units[j].value })
|
||||
var refunds []GrowthMaterial
|
||||
remaining := overflow
|
||||
for i, unit := range units {
|
||||
count := remaining / unit.value
|
||||
if i == len(units)-1 && remaining%unit.value != 0 {
|
||||
count++
|
||||
}
|
||||
if count != 0 {
|
||||
refunds = append(refunds, GrowthMaterial{ID: unit.id, Count: count})
|
||||
if count > remaining/unit.value {
|
||||
remaining = 0
|
||||
} else {
|
||||
remaining -= count * unit.value
|
||||
}
|
||||
}
|
||||
}
|
||||
return refunds, nil
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package gamedata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type HuntingMonster struct {
|
||||
ID, Type uint64
|
||||
Decks []uint64
|
||||
Rewards map[uint64][]BattleReward
|
||||
}
|
||||
type HuntingGround struct {
|
||||
ID, MapID, Difficulty, BossID uint64
|
||||
Monsters []uint64
|
||||
}
|
||||
type HuntingPack struct {
|
||||
Grounds []HuntingGround
|
||||
Monsters map[uint64]HuntingMonster
|
||||
NormalAP, BossAP uint64
|
||||
}
|
||||
|
||||
func LoadHuntingPack(root, version string, pack int) (*HuntingPack, error) {
|
||||
db, cleanup, err := openPackDatabase(root, version, pack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cleanup()
|
||||
common, closeCommon, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeCommon()
|
||||
d := &HuntingPack{Monsters: map[uint64]HuntingMonster{}}
|
||||
var defaults []byte
|
||||
if err := common.QueryRow("SELECT ProtoBuf FROM GameDefaultTable").Scan(&defaults); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.BossAP, err = optionalScalar(defaults, 121)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.NormalAP, err = optionalScalar(defaults, 122)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := db.Query("SELECT ProtoBuf FROM HuntingGroundTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
g := HuntingGround{}
|
||||
for field, target := range map[int]*uint64{1: &g.BossID, 2: &g.Difficulty, 3: &g.ID, 4: &g.MapID} {
|
||||
v, e := optionalScalar(raw, field)
|
||||
if e != nil {
|
||||
_ = rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
*target = v
|
||||
}
|
||||
g.Monsters, err = packedInts(raw, 5)
|
||||
if err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if g.ID == 0 || g.MapID == 0 || g.BossID == 0 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid hunting ground")
|
||||
}
|
||||
d.Grounds = append(d.Grounds, g)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
_ = rows.Close()
|
||||
for _, g := range d.Grounds {
|
||||
ids := append(append([]uint64(nil), g.Monsters...), g.BossID)
|
||||
for _, id := range ids {
|
||||
if _, ok := d.Monsters[id]; ok {
|
||||
continue
|
||||
}
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM FieldMonsterTable WHERE id=?", id).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := HuntingMonster{ID: id, Rewards: map[uint64][]BattleReward{}}
|
||||
m.Type, err = optionalScalar(raw, 30)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Decks, err = packedInts(raw, 2)
|
||||
if err != nil || len(m.Decks) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: missing hunting monster deck")
|
||||
}
|
||||
for _, deck := range m.Decks {
|
||||
var b []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM BattleDeckTable WHERE id=?", deck).Scan(&b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, e := parallelRewards(b, 36, 34, 33)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
types, e := packedInts(b, 36)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if len(r) != len(types) {
|
||||
return nil, fmt.Errorf("gamedata: hunting reward arrays mismatch")
|
||||
}
|
||||
for _, reward := range r {
|
||||
m.Rewards[deck] = append(m.Rewards[deck], BattleReward{reward.Type, reward.ID, reward.Count}) //nolint:staticcheck // S1016
|
||||
}
|
||||
}
|
||||
d.Monsters[id] = m
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/protocol/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
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package gamedata
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ImmortalDesign resolves exactly the current character talent and skill level.
|
||||
// Full restoration is evidenced for value 10000; other values require official
|
||||
// server evidence of their rounding/scaling semantics before being enabled.
|
||||
type ImmortalDesign struct {
|
||||
Characters map[uint64]uint64
|
||||
FullRestore map[[2]uint64]bool
|
||||
}
|
||||
|
||||
func LoadImmortalDesign(root, version string) (*ImmortalDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
d := &ImmortalDesign{Characters: map[uint64]uint64{}, FullRestore: map[[2]uint64]bool{}}
|
||||
talents := map[uint64]uint64{}
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM TalentTable")
|
||||
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
|
||||
}
|
||||
group, err := optionalScalar(raw, 18)
|
||||
if err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
talents[id] = group
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
_ = rows.Close()
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable")
|
||||
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
|
||||
}
|
||||
talent, err := optionalScalar(raw, 18)
|
||||
if err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if group := talents[talent]; group != 0 {
|
||||
d.Characters[id] = group
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
_ = rows.Close()
|
||||
rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM TalentSkillTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var group, level uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&group, &level, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
class, err := optionalScalar(raw, 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if class != 14 {
|
||||
continue
|
||||
}
|
||||
values, err := fixed32Floats(raw, 14)
|
||||
if err != nil || len(values) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: immortal skill missing value")
|
||||
}
|
||||
d.FullRestore[[2]uint64{group, level}] = values[0] == 10000
|
||||
}
|
||||
return d, rows.Err()
|
||||
}
|
||||
|
||||
func (d *ImmortalDesign) CanRestore(characterID, level uint64) bool {
|
||||
return d != nil && d.FullRestore[[2]uint64{d.Characters[characterID], level}]
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type InnRule struct {
|
||||
NPCID, MapID, MapGroup, Currency, ItemCount, FreeSquadLevel, GoodDiscount uint64
|
||||
}
|
||||
|
||||
// LoadInns reads motel NPCs from the selected pack and their shared recovery
|
||||
// rules. The /40 conversion is the client's HP pricing unit, not a pack fact.
|
||||
func LoadInns(root, version string, pack int) ([]InnRule, error) {
|
||||
db, release, err := openPackDatabase(root, version, pack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
shared, done, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
return loadInns(db, shared)
|
||||
}
|
||||
|
||||
func loadInns(db, shared *sql.DB) ([]InnRule, error) {
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM FieldNpcTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rules []InnRule
|
||||
var recoveryIDs []uint64
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err = rows.Scan(&id, &raw); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
interactions, e := packedInts(raw, 9)
|
||||
if e != nil {
|
||||
_ = rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
if !slices.Contains(interactions, uint64(2)) {
|
||||
continue
|
||||
}
|
||||
mapID, e := optionalScalar(raw, 14)
|
||||
if e != nil {
|
||||
_ = rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
recovery, e := optionalScalar(raw, 21)
|
||||
if e != nil || recovery == 0 || mapID == 0 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid motel NPC %d", id)
|
||||
}
|
||||
rules = append(rules, InnRule{NPCID: id, MapID: mapID})
|
||||
recoveryIDs = append(recoveryIDs, recovery)
|
||||
}
|
||||
err = rows.Err()
|
||||
_ = rows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range rules {
|
||||
r := &rules[i]
|
||||
var raw []byte
|
||||
if err = shared.QueryRow("SELECT ProtoBuf FROM CharRecoveryTable WHERE id=?", recoveryIDs[i]).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for field, target := range map[int]*uint64{3: &r.ItemCount, 4: &r.Currency, 5: &r.FreeSquadLevel} {
|
||||
*target, err = optionalScalar(raw, field)
|
||||
if err != nil || *target > math.MaxInt32 {
|
||||
return nil, fmt.Errorf("gamedata: invalid inn recovery rule")
|
||||
}
|
||||
}
|
||||
if r.Currency != 4 || r.ItemCount == 0 {
|
||||
return nil, fmt.Errorf("gamedata: unsupported inn currency or HP rate")
|
||||
}
|
||||
if err = shared.QueryRow("SELECT ProtoBuf FROM MapTable WHERE id=?", r.MapID).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.MapGroup, err = optionalScalar(raw, 18)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = db.QueryRow("SELECT ProtoBuf FROM ReputationGroupTable WHERE id=?", r.MapGroup).Scan(&raw)
|
||||
if err == nil {
|
||||
r.GoodDiscount, err = optionalScalar(raw, 2)
|
||||
}
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if r.GoodDiscount > 100 {
|
||||
return nil, fmt.Errorf("gamedata: invalid motel discount")
|
||||
}
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// InventorySlotRule is one of the four capacities represented directly in
|
||||
// UserDBInfo. Prices and limits belong to the current GameData version.
|
||||
type InventorySlotRule struct {
|
||||
Default uint64
|
||||
Maximum uint64
|
||||
PriceType uint64
|
||||
BasePrice uint64
|
||||
MaxPrice uint64
|
||||
}
|
||||
|
||||
type InventorySlotDesign struct {
|
||||
Items InventorySlotRule
|
||||
Storage InventorySlotRule
|
||||
Equipment InventorySlotRule
|
||||
EquipmentStorage InventorySlotRule
|
||||
}
|
||||
|
||||
func LoadInventorySlotDesign(root, version string) (*InventorySlotDesign, error) {
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
return loadInventorySlotDesign(db)
|
||||
}
|
||||
|
||||
func loadInventorySlotDesign(db *sql.DB) (*InventorySlotDesign, error) {
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&raw); err != nil {
|
||||
return nil, fmt.Errorf("gamedata: inventory slot defaults: %w", err)
|
||||
}
|
||||
read := func(field int) (uint64, error) {
|
||||
values, err := packedInts(raw, field)
|
||||
if err != nil || len(values) != 1 || values[0] == 0 {
|
||||
return 0, fmt.Errorf("gamedata: inventory slot field %d is missing or invalid", field)
|
||||
}
|
||||
return values[0], nil
|
||||
}
|
||||
maxPrice, err := read(79)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
makeRule := func(defaultField, maximumField, priceTypeField, basePriceField int) (InventorySlotRule, error) {
|
||||
initial, err := read(defaultField)
|
||||
if err != nil {
|
||||
return InventorySlotRule{}, err
|
||||
}
|
||||
maximum, err := read(maximumField)
|
||||
if err != nil {
|
||||
return InventorySlotRule{}, err
|
||||
}
|
||||
priceType, err := read(priceTypeField)
|
||||
if err != nil {
|
||||
return InventorySlotRule{}, err
|
||||
}
|
||||
basePrice, err := read(basePriceField)
|
||||
if err != nil {
|
||||
return InventorySlotRule{}, err
|
||||
}
|
||||
rule := InventorySlotRule{Default: initial, Maximum: maximum, PriceType: priceType, BasePrice: basePrice, MaxPrice: maxPrice}
|
||||
if rule.Default > rule.Maximum || (rule.PriceType != 2 && rule.PriceType != 3 && rule.PriceType != 4) {
|
||||
return InventorySlotRule{}, errors.New("gamedata: unsupported inventory slot rule")
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
design := &InventorySlotDesign{}
|
||||
if design.Items, err = makeRule(34, 80, 14, 13); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if design.Storage, err = makeRule(37, 83, 16, 15); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if design.Equipment, err = makeRule(30, 73, 10, 9); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if design.EquipmentStorage, err = makeRule(31, 74, 12, 11); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return design, nil
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
type ItemCraftRecipe struct {
|
||||
ID, Class, TalentLevel, Category uint64
|
||||
Result BattleReward
|
||||
Costs []PromotionCost
|
||||
}
|
||||
type ItemCraftDesign struct{ Cooking, Alchemy map[uint64]ItemCraftRecipe }
|
||||
|
||||
func LoadItemCraftDesign(root, version string) (*ItemCraftDesign, error) {
|
||||
db, done, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
return loadItemCraftDesign(db)
|
||||
}
|
||||
func loadItemCraftDesign(db *sql.DB) (*ItemCraftDesign, error) {
|
||||
d := &ItemCraftDesign{Cooking: map[uint64]ItemCraftRecipe{}, Alchemy: map[uint64]ItemCraftRecipe{}}
|
||||
for _, name := range []string{"CookingTable", "AlchemyTable"} {
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM " + name + " ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err = rows.Scan(&id, &raw); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
r := ItemCraftRecipe{ID: id, Class: 8}
|
||||
if name == "AlchemyTable" {
|
||||
r.Category, err = optionalScalar(raw, 1)
|
||||
if err != nil || (r.Category != 1 && r.Category != 2) {
|
||||
return nil, fmt.Errorf("gamedata: invalid alchemy category %d", id)
|
||||
}
|
||||
}
|
||||
countField, idField, typeField, levelField, materialCount, materialID, materialType := 9, 10, 11, 12, 5, 6, 7
|
||||
if name == "CookingTable" {
|
||||
r.Class = 7
|
||||
countField, idField, typeField, levelField, materialCount, materialID, materialType = 12, 13, 0, 15, 7, 8, 0
|
||||
r.Result.Type = 5
|
||||
}
|
||||
for f, p := range map[int]*uint64{countField: &r.Result.Count, idField: &r.Result.ID, levelField: &r.TalentLevel} {
|
||||
*p, err = optionalScalar(raw, f)
|
||||
if err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if typeField != 0 {
|
||||
r.Result.Type, err = optionalScalar(raw, typeField)
|
||||
if err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
ns, e1 := packedInts(raw, materialCount)
|
||||
ids, e2 := packedInts(raw, materialID)
|
||||
var ts []uint64
|
||||
var e3 error
|
||||
if materialType != 0 {
|
||||
ts, e3 = packedInts(raw, materialType)
|
||||
} else {
|
||||
for range ids {
|
||||
ts = append(ts, 5)
|
||||
}
|
||||
}
|
||||
if e1 != nil || e2 != nil || e3 != nil || len(ns) == 0 || len(ns) != len(ids) || len(ts) != len(ids) || id == 0 || id > math.MaxInt32 || r.Result.ID == 0 || r.Result.Count == 0 || r.Result.Count > math.MaxInt32 || r.TalentLevel == 0 {
|
||||
return nil, fmt.Errorf("gamedata: malformed %s recipe %d", name, id)
|
||||
}
|
||||
for i, n := range ns {
|
||||
if n == 0 || n > math.MaxInt32 || ids[i] == 0 || ids[i] > math.MaxInt32 || ts[i] == 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid recipe materials")
|
||||
}
|
||||
r.Costs = append(r.Costs, PromotionCost{Type: ts[i], ID: ids[i], Count: n})
|
||||
}
|
||||
if r.Class == 7 {
|
||||
d.Cooking[id] = r
|
||||
} else {
|
||||
d.Alchemy[id] = r
|
||||
}
|
||||
}
|
||||
err = rows.Err()
|
||||
_ = rows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// CraftTalent validates the producer and recipe against the same talent and
|
||||
// growth catalog used by ordinary field skills. Recipe level sets cost; the
|
||||
// producer's current level sets experience, as in the client craft UI.
|
||||
func (d *TalentUseDesign) CraftTalent(characterID, level, class, recipeLevel, count, experience uint64) (gain, catalyst, maximum uint64, err error) {
|
||||
c, ok := d.Characters[characterID]
|
||||
if !ok || level == 0 || level > c.MaxLevel || recipeLevel == 0 || level < recipeLevel || count == 0 || count > math.MaxInt32 {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: unavailable crafting talent")
|
||||
}
|
||||
current, ok := d.Rules[[2]uint64{c.Group, level}]
|
||||
if !ok || current.Class != class {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: wrong crafting talent class")
|
||||
}
|
||||
recipe, ok := d.Rules[[2]uint64{c.Group, recipeLevel}]
|
||||
if !ok {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: missing recipe talent level")
|
||||
}
|
||||
if d.Growth == nil {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: missing talent growth")
|
||||
}
|
||||
g, ok := d.Growth.Characters[characterID]
|
||||
if !ok {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: missing craft growth group")
|
||||
}
|
||||
for l := uint64(1); l <= level; l++ {
|
||||
entry, exists := d.Growth.Levels[[2]uint64{g.GrowthGroup, l}]
|
||||
if !exists {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: missing growth level")
|
||||
}
|
||||
if entry.NeedExp > math.MaxUint64-maximum {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: craft growth overflow")
|
||||
}
|
||||
maximum += entry.NeedExp
|
||||
}
|
||||
if recipe.Catalyst > math.MaxInt32/count || current.Experience > math.MaxInt32/count {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: crafting quantity overflow")
|
||||
}
|
||||
gain = current.Experience * count
|
||||
if experience >= maximum {
|
||||
gain = 0
|
||||
} else if gain > maximum-experience {
|
||||
gain = maximum - experience
|
||||
}
|
||||
return gain, recipe.Catalyst * count, maximum, nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
type ItemStackDesign struct {
|
||||
Limits map[[2]uint64]uint64
|
||||
}
|
||||
|
||||
func LoadItemStackDesign(root, version string) (*ItemStackDesign, error) {
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
design := &ItemStackDesign{Limits: map[[2]uint64]uint64{}}
|
||||
for _, source := range []struct {
|
||||
name string
|
||||
kind, idField, limitField uint64
|
||||
}{
|
||||
{"FoodTable", 5, 7, 16}, {"ResourceTable", 8, 4, 12},
|
||||
{"RandomBoxTable", 9, 4, 12}, {"UseItemTable", 14, 3, 10},
|
||||
} {
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM " + source.name + " 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
|
||||
}
|
||||
protoID, err := optionalScalar(raw, int(source.idField))
|
||||
if err != nil || id == 0 || protoID != id {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid %s stack identity %d", source.name, id)
|
||||
}
|
||||
limit, err := optionalScalar(raw, int(source.limitField))
|
||||
if err != nil || limit > math.MaxInt32 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid %s stack limit %d", source.name, id)
|
||||
}
|
||||
if limit > 0 {
|
||||
design.Limits[[2]uint64{source.kind, id}] = limit
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return design, nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// LimitedCostumeCatalog is the authoritative LimitedCostumeTable membership
|
||||
// plus the character/growth design required to grant those costumes. It is a
|
||||
// reward lookup only and never changes any gacha pool.
|
||||
type LimitedCostumeCatalog struct {
|
||||
ids []uint64
|
||||
characters map[uint64]CharacterDesign
|
||||
}
|
||||
|
||||
func LoadLimitedCostumes(root, version string) (*LimitedCostumeCatalog, error) {
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
rows, err := db.Query("SELECT id FROM LimitedCostumeTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
catalog := &LimitedCostumeCatalog{characters: map[uint64]CharacterDesign{}}
|
||||
for rows.Next() {
|
||||
var costumeID uint64
|
||||
if err := rows.Scan(&costumeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if costumeID == 0 {
|
||||
return nil, errors.New("gamedata: limited costume has zero id")
|
||||
}
|
||||
catalog.ids = append(catalog.ids, costumeID)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (c *LimitedCostumeCatalog) IDs() []uint64 {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return append([]uint64(nil), c.ids...)
|
||||
}
|
||||
|
||||
func (c *LimitedCostumeCatalog) Character(costumeID uint64) (CharacterDesign, bool) {
|
||||
if c == nil {
|
||||
return CharacterDesign{}, false
|
||||
}
|
||||
design, ok := c.characters[costumeID]
|
||||
return design, ok
|
||||
}
|
||||
|
||||
// Excluding returns stable LimitedCostumeTable order without the supplied
|
||||
// currently featured pickup costume IDs.
|
||||
func (c *LimitedCostumeCatalog) Excluding(excluded map[uint64]bool) []uint64 {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
ids := make([]uint64, 0, len(c.ids))
|
||||
for _, id := range c.ids {
|
||||
if !excluded[id] {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package gamedata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type MiniContentRoute struct {
|
||||
UID, ContentType, ContentID uint64
|
||||
Start, End int64
|
||||
}
|
||||
|
||||
type MiniContentDesign struct {
|
||||
Stories map[uint64]Reward
|
||||
Groups map[uint64][]uint64
|
||||
}
|
||||
|
||||
func LoadMiniContentDesign(root, version string) (*MiniContentDesign, error) {
|
||||
db, done, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
d := &MiniContentDesign{Stories: map[uint64]Reward{}, Groups: map[uint64][]uint64{}}
|
||||
rows, err := db.Query("SELECT ProtoBuf FROM DailyStoryTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err = rows.Scan(&raw); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
id, e := optionalScalar(raw, 2)
|
||||
if e != nil {
|
||||
_ = rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
var r Reward
|
||||
for field, target := range map[int]*uint64{10: &r.Type, 9: &r.ID, 8: &r.Count} {
|
||||
v, e := optionalScalar(raw, field)
|
||||
if e != nil {
|
||||
_ = rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
*target = v
|
||||
}
|
||||
d.Stories[id] = r
|
||||
}
|
||||
err = rows.Err()
|
||||
_ = rows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT ProtoBuf FROM MiniEventStoryTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err = rows.Scan(&raw); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
id, e := optionalScalar(raw, 1)
|
||||
if e != nil {
|
||||
_ = rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
group, e := optionalScalar(raw, 2)
|
||||
if e != nil {
|
||||
_ = rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
d.Groups[group] = append(d.Groups[group], id)
|
||||
}
|
||||
err = rows.Err()
|
||||
_ = rows.Close()
|
||||
if err == nil {
|
||||
for group, ids := range d.Groups {
|
||||
for _, id := range ids {
|
||||
if _, exists := d.Stories[id]; !exists {
|
||||
return nil, fmt.Errorf("gamedata: mini story group %d references missing daily story %d", group, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return d, err
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package gamedata
|
||||
|
||||
// MiniHubEventType maps PackEventListTable.HubContentType to the independent
|
||||
// EventSchedule enum used by playable slot handlers. Quiz and daily story have
|
||||
// no global event type and cannot be bound by borrowing another game's UID.
|
||||
func MiniHubEventType(contentType uint64) (uint64, bool) {
|
||||
switch contentType {
|
||||
case 4:
|
||||
return 7, true
|
||||
case 5:
|
||||
return 12, true
|
||||
case 6:
|
||||
return 11, true
|
||||
case 7:
|
||||
return 13, true
|
||||
case 11:
|
||||
return 17, true
|
||||
case 12:
|
||||
return 19, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Reward is an immutable GameData reward definition. Type, ID and Count are
|
||||
// the exact parallel arrays stored by the client design tables; it is not a
|
||||
// player inventory row.
|
||||
type Reward struct {
|
||||
Type uint64
|
||||
ID uint64
|
||||
Count uint64
|
||||
}
|
||||
|
||||
type MissionKey struct{ GroupType, GroupID, ID uint64 }
|
||||
type SectionRewardKey struct{ GroupType, ID uint64 }
|
||||
type AchievementKey struct{ ContentsGroup, GroupID, ID uint64 }
|
||||
|
||||
type MissionDesign struct {
|
||||
Missions map[MissionKey][]Reward
|
||||
Conditions map[MissionKey]MissionCondition
|
||||
Sections map[SectionRewardKey]SectionRewardDesign
|
||||
Achievements map[AchievementKey]AchievementDesign
|
||||
}
|
||||
|
||||
type MissionCondition struct {
|
||||
Type uint64
|
||||
SubType uint64
|
||||
SubTypeComparison uint64
|
||||
Params []uint64
|
||||
TargetValue uint64
|
||||
UnlockPack uint64
|
||||
UnlockQuest uint64
|
||||
}
|
||||
|
||||
type SectionRewardDesign struct {
|
||||
SectionValue uint64
|
||||
Rewards []Reward
|
||||
}
|
||||
|
||||
type AchievementDesign struct {
|
||||
Target float64
|
||||
CounterGroup uint64
|
||||
AddExp uint64
|
||||
Rewards []Reward
|
||||
}
|
||||
|
||||
// LoadMissionDesign loads the three non-event design tables from the shared
|
||||
// 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) {
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
return loadMissionDesignDB(db)
|
||||
}
|
||||
|
||||
func loadMissionDesignDB(db *sql.DB) (*MissionDesign, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("gamedata: nil mission database")
|
||||
}
|
||||
design := &MissionDesign{
|
||||
Missions: map[MissionKey][]Reward{}, Conditions: map[MissionKey]MissionCondition{}, Sections: map[SectionRewardKey]SectionRewardDesign{},
|
||||
Achievements: map[AchievementKey]AchievementDesign{},
|
||||
}
|
||||
if err := loadMissionRows(db, design); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadSectionRows(db, design); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadAchievementRows(db, design); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return design, nil
|
||||
}
|
||||
|
||||
func loadMissionRows(db *sql.DB, design *MissionDesign) error {
|
||||
rows, err := db.Query("SELECT ProtoBuf FROM MissionTable")
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: query MissionTable: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var proto []byte
|
||||
if err := rows.Scan(&proto); err != nil {
|
||||
return err
|
||||
}
|
||||
groupID, err := optionalScalar(proto, 6)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: MissionTable group id: %w", err)
|
||||
}
|
||||
groupType, err := optionalScalar(proto, 7)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: MissionTable group type: %w", err)
|
||||
}
|
||||
id, err := optionalScalar(proto, 8)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: MissionTable id: %w", err)
|
||||
}
|
||||
// GroupType zero is DAILY, a valid proto3 default. Rows without a
|
||||
// group/id identity remain non-claimable templates.
|
||||
if groupID == 0 || id == 0 {
|
||||
continue
|
||||
}
|
||||
rewards, err := parallelRewards(proto, 15, 14, 13)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: MissionTable %d/%d/%d: %w", groupType, groupID, id, err)
|
||||
}
|
||||
key := MissionKey{groupType, groupID, id}
|
||||
if _, exists := design.Missions[key]; exists {
|
||||
return fmt.Errorf("gamedata: duplicate MissionTable key %+v", key)
|
||||
}
|
||||
design.Missions[key] = rewards
|
||||
conditionType, err := optionalScalar(proto, 3)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: MissionTable condition type: %w", err)
|
||||
}
|
||||
conditionSubType, err := optionalScalar(proto, 1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: MissionTable condition subtype: %w", err)
|
||||
}
|
||||
params, err := packedInts(proto, 2)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: MissionTable params: %w", err)
|
||||
}
|
||||
target, err := optionalScalar(proto, 4)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: MissionTable target: %w", err)
|
||||
}
|
||||
unlockPack, err := optionalScalar(proto, 19)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: MissionTable unlock pack: %w", err)
|
||||
}
|
||||
unlockQuest, err := optionalScalar(proto, 20)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: MissionTable unlock quest: %w", err)
|
||||
}
|
||||
subTypeMore, err := optionalScalar(proto, 9)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: MissionTable subtype comparison invalid")
|
||||
}
|
||||
design.Conditions[key] = MissionCondition{SubTypeComparison: subTypeMore, Type: conditionType, SubType: conditionSubType, Params: params, TargetValue: target, UnlockPack: unlockPack, UnlockQuest: unlockQuest}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func loadSectionRows(db *sql.DB, design *MissionDesign) error {
|
||||
rows, err := db.Query("SELECT ProtoBuf FROM MissionSectionRewardTable")
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: query MissionSectionRewardTable: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var proto []byte
|
||||
if err := rows.Scan(&proto); err != nil {
|
||||
return err
|
||||
}
|
||||
groupType, err := optionalScalar(proto, 1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: MissionSectionRewardTable group type: %w", err)
|
||||
}
|
||||
id, err := optionalScalar(proto, 2)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: MissionSectionRewardTable id: %w", err)
|
||||
}
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
rewards, err := parallelRewards(proto, 5, 4, 3)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: MissionSectionRewardTable %d/%d: %w", groupType, id, err)
|
||||
}
|
||||
key := SectionRewardKey{groupType, id}
|
||||
if _, exists := design.Sections[key]; exists {
|
||||
return fmt.Errorf("gamedata: duplicate MissionSectionRewardTable key %+v", key)
|
||||
}
|
||||
sectionValue, err := optionalScalar(proto, 6)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: MissionSectionRewardTable section value: %w", err)
|
||||
}
|
||||
design.Sections[key] = SectionRewardDesign{SectionValue: sectionValue, Rewards: rewards}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func loadAchievementRows(db *sql.DB, design *MissionDesign) error {
|
||||
rows, err := db.Query("SELECT ProtoBuf FROM AchievementTable")
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: query AchievementTable: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var proto []byte
|
||||
if err := rows.Scan(&proto); err != nil {
|
||||
return err
|
||||
}
|
||||
contents, err := optionalScalar(proto, 4)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: AchievementTable contents group: %w", err)
|
||||
}
|
||||
groupID, err := optionalScalar(proto, 9)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: AchievementTable group id: %w", err)
|
||||
}
|
||||
id, err := optionalScalar(proto, 11)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: AchievementTable id: %w", err)
|
||||
}
|
||||
if groupID == 0 || id == 0 {
|
||||
continue
|
||||
}
|
||||
exp, err := optionalScalar(proto, 8)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: AchievementTable exp: %w", err)
|
||||
}
|
||||
rewards, err := parallelRewards(proto, 16, 15, 14)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: AchievementTable %d/%d/%d: %w", contents, groupID, id, err)
|
||||
}
|
||||
parent, err := optionalScalar(proto, 13)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
useType, err := optionalScalar(proto, 21)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if parent != 0 || useType != 0 {
|
||||
continue
|
||||
}
|
||||
key := AchievementKey{contents, groupID, id}
|
||||
if _, exists := design.Achievements[key]; exists {
|
||||
return fmt.Errorf("gamedata: duplicate AchievementTable key %+v", key)
|
||||
}
|
||||
target, found, err := fixed64Double(proto, 3)
|
||||
if err != nil || !found || target < 0 || math.IsNaN(target) || math.IsInf(target, 0) {
|
||||
return fmt.Errorf("gamedata: invalid achievement target")
|
||||
}
|
||||
counter := groupID
|
||||
design.Achievements[key] = AchievementDesign{Target: target, CounterGroup: counter, AddExp: exp, Rewards: rewards}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func parallelRewards(proto []byte, typeField, idField, countField int) ([]Reward, error) {
|
||||
types, err := packedInts(proto, typeField)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids, err := packedInts(proto, idField)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts, err := packedInts(proto, countField)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ids) == 0 && len(types) == len(counts) {
|
||||
ids = make([]uint64, len(types))
|
||||
}
|
||||
if len(types) != len(ids) || len(ids) != len(counts) {
|
||||
// A design row may be a mission marker with only display/condition
|
||||
// values populated. Keeping an empty reward list preserves the
|
||||
// claimable identity without inventing an item from partial arrays.
|
||||
return nil, nil
|
||||
}
|
||||
rewards := make([]Reward, len(types))
|
||||
for i := range rewards {
|
||||
if types[i] == 0 || counts[i] == 0 {
|
||||
return nil, fmt.Errorf("invalid reward at %d", i)
|
||||
}
|
||||
rewards[i] = Reward{Type: types[i], ID: ids[i], Count: counts[i]}
|
||||
}
|
||||
return rewards, nil
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
type MonsterHuntRewards struct{ Clear, Daily []BattleReward }
|
||||
type MonsterHuntRankReward struct {
|
||||
ID, Type uint64
|
||||
Ranking float64
|
||||
Rewards []BattleReward
|
||||
}
|
||||
|
||||
// MonsterHunt is the immutable common database design. Hunt decks live in
|
||||
// common.db, as BattleDeckInfo.GetBattleDeckInfo(id, true) specifies.
|
||||
type MonsterHunt struct {
|
||||
ID, PackID, MonsterID, DeckID, RewardGroupID, MaxLevel, RewardLevel, ChallengeableLevel uint64
|
||||
TeamOpenLevels []uint64
|
||||
Rewards map[uint64]MonsterHuntRewards
|
||||
Ranks map[uint64][]MonsterHuntRankReward
|
||||
baseHP, healthRate, healthSlope, stage2Ratio, stage3Ratio float64
|
||||
stage2Level, stage3Level uint64
|
||||
}
|
||||
|
||||
func LoadMonsterHunt(root, version string, id uint64) (*MonsterHunt, error) {
|
||||
db, done, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
return loadMonsterHunt(db, id)
|
||||
}
|
||||
func loadMonsterHunt(db *sql.DB, id uint64) (*MonsterHunt, error) {
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("gamedata: zero monster hunt")
|
||||
}
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM MonsterHuntTable WHERE id=?", id).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := &MonsterHunt{Rewards: map[uint64]MonsterHuntRewards{}, Ranks: map[uint64][]MonsterHuntRankReward{}}
|
||||
for n, p := range map[int]*uint64{8: &d.ID, 18: &d.PackID, 16: &d.MonsterID, 1: &d.DeckID, 22: &d.RewardGroupID, 23: &d.RewardLevel, 14: &d.ChallengeableLevel, 26: &d.stage2Level, 28: &d.stage3Level} {
|
||||
v, e := optionalScalar(raw, n)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
*p = v
|
||||
}
|
||||
for n, p := range map[int]*float64{11: &d.healthRate, 12: &d.healthSlope, 27: &d.stage2Ratio, 29: &d.stage3Ratio} {
|
||||
v, _, e := fixed64Double(raw, n)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
*p = v
|
||||
}
|
||||
var err error
|
||||
d.TeamOpenLevels, err = packedInts(raw, 32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
group, err := optionalScalar(raw, 19)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := db.Query("SELECT ProtoBuf FROM MonsterHuntPartsTable WHERE groupId=? ORDER BY id", group)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var body uint64
|
||||
for rows.Next() {
|
||||
var part []byte
|
||||
if err = rows.Scan(&part); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
v, e := optionalScalar(part, 7)
|
||||
if e != nil {
|
||||
_ = rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
if body == 0 && v > 0 {
|
||||
body = v
|
||||
}
|
||||
}
|
||||
err = rows.Err()
|
||||
_ = rows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body == 0 {
|
||||
return nil, fmt.Errorf("gamedata: hunt %d missing body", id)
|
||||
}
|
||||
var deck []byte
|
||||
if err = db.QueryRow("SELECT ProtoBuf FROM BattleDeckTable WHERE id=?", d.DeckID).Scan(&deck); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chars, err := packedInts(deck, 14)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
levels, err := packedInts(deck, 30)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(chars) != len(levels) {
|
||||
return nil, fmt.Errorf("gamedata: hunt deck level arrays differ")
|
||||
}
|
||||
level := uint64(1)
|
||||
for i, c := range chars {
|
||||
if c == body {
|
||||
level = levels[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
stats, err := loadCharacterStatDesign(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base, err := stats.BaseStats(body, level)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deckRate, _, err := fixed64Double(deck, 28)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.baseHP = math.Trunc(base.Health*deckRate*100) / 100
|
||||
rows, err = db.Query("SELECT level, ProtoBuf FROM MonsterHuntRewardTable WHERE groupId=? ORDER BY level", d.RewardGroupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var lv uint64
|
||||
var r []byte
|
||||
if err = rows.Scan(&lv, &r); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
clear, e := monsterHuntRewardArrays(r, 8, 7, 6)
|
||||
if e != nil {
|
||||
_ = rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
daily, e := monsterHuntRewardArrays(r, 3, 2, 1)
|
||||
if e != nil {
|
||||
_ = rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
d.Rewards[lv] = MonsterHuntRewards{clear, daily}
|
||||
if lv > d.MaxLevel {
|
||||
d.MaxLevel = lv
|
||||
}
|
||||
}
|
||||
err = rows.Err()
|
||||
_ = rows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM MonsterHuntRankTable ORDER BY groupId,id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var g uint64
|
||||
var r MonsterHuntRankReward
|
||||
var p []byte
|
||||
if err = rows.Scan(&g, &r.ID, &p); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
r.Type, err = optionalScalar(p, 6)
|
||||
if err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
r.Ranking, _, err = fixed64Double(p, 5)
|
||||
if err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
r.Rewards, err = monsterHuntRewardArrays(p, 9, 8, 7)
|
||||
if err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
d.Ranks[g] = append(d.Ranks[g], r)
|
||||
}
|
||||
err = rows.Err()
|
||||
_ = rows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.ID != id || d.PackID == 0 || d.DeckID == 0 || d.MaxLevel == 0 || d.baseHP <= 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid hunt %d design", id)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// HP matches MonsterHuntInfo.ApplyMonsterHuntStatRate, including banker
|
||||
// rounding and truncation to three significant decimal digits.
|
||||
func (d *MonsterHunt) HP(level uint64) (uint64, error) {
|
||||
if d == nil || level == 0 || level > d.MaxLevel {
|
||||
return 0, fmt.Errorf("gamedata: invalid hunt level %d", level)
|
||||
}
|
||||
ratio := 1.0
|
||||
if level > d.stage2Level {
|
||||
ratio = d.stage2Ratio
|
||||
if level > d.stage3Level {
|
||||
ratio = d.stage3Ratio
|
||||
}
|
||||
}
|
||||
rate := 1 + float64(level-1)*d.healthRate*.01*math.Pow(float64(level), d.healthSlope)*ratio
|
||||
hp := math.RoundToEven(d.baseHP * rate)
|
||||
if math.IsNaN(hp) || math.IsInf(hp, 0) || hp < 1 || hp >= float64(math.MaxInt64) {
|
||||
return 0, fmt.Errorf("gamedata: hunt HP overflow")
|
||||
}
|
||||
value := uint64(hp)
|
||||
scale := uint64(1)
|
||||
for value/scale >= 1000 {
|
||||
scale *= 10
|
||||
}
|
||||
return value / scale * scale, nil
|
||||
}
|
||||
func monsterHuntRewardArrays(raw []byte, typeField, idField, countField int) ([]BattleReward, error) {
|
||||
types, e := packedInts(raw, typeField)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
ids, e := packedInts(raw, idField)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
counts, e := packedInts(raw, countField)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if len(types) != len(ids) || len(ids) != len(counts) {
|
||||
return nil, fmt.Errorf("gamedata: hunt reward arrays differ")
|
||||
}
|
||||
var result []BattleReward
|
||||
for i, t := range types {
|
||||
if t > 0 && counts[i] > 0 {
|
||||
result = append(result, BattleReward{Type: t, ID: ids[i], Count: counts[i]})
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func LoadMonsterHuntPresetDesign(root, version string) (*PresetDesign, error) {
|
||||
db, done, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
d, err := loadPresetDesign(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []byte
|
||||
if err = db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.BaseCount, err = optionalScalar(raw, 84)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Maximum, err = optionalScalar(raw, 85)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, d.Validate()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package gamedata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type NPCReputationRule struct{ ID, DownHours, GoodInn, GoodPrice uint64 }
|
||||
type NPCReputationDesign struct {
|
||||
Groups map[uint64]NPCReputationRule
|
||||
MapGroups map[int]uint64
|
||||
}
|
||||
|
||||
func LoadNPCReputation(root, version string, pack int) (NPCReputationDesign, error) {
|
||||
d := NPCReputationDesign{Groups: map[uint64]NPCReputationRule{}, MapGroups: map[int]uint64{}}
|
||||
db, done, err := openPackDatabase(root, version, pack)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
defer done()
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM ReputationGroupTable")
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err = rows.Scan(&id, &raw); err != nil {
|
||||
_ = rows.Close()
|
||||
return d, err
|
||||
}
|
||||
r := NPCReputationRule{ID: id}
|
||||
for f, p := range map[int]*uint64{1: &r.DownHours, 2: &r.GoodInn, 3: &r.GoodPrice} {
|
||||
*p, err = optionalScalar(raw, f)
|
||||
if err != nil {
|
||||
_ = rows.Close()
|
||||
return d, err
|
||||
}
|
||||
}
|
||||
if id == 0 || r.GoodInn > 100 || r.GoodPrice > 100 || r.DownHours > 596523 {
|
||||
_ = rows.Close()
|
||||
return d, fmt.Errorf("gamedata: invalid reputation rule")
|
||||
}
|
||||
d.Groups[id] = r
|
||||
}
|
||||
err = rows.Err()
|
||||
_ = rows.Close()
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
shared, release, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
defer release()
|
||||
rows, err = shared.Query("SELECT id,ProtoBuf FROM MapTable")
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var raw []byte
|
||||
if err = rows.Scan(&id, &raw); err != nil {
|
||||
return d, err
|
||||
}
|
||||
p, e := optionalScalar(raw, 28)
|
||||
if e != nil {
|
||||
return d, e
|
||||
}
|
||||
if p != uint64(pack) {
|
||||
continue
|
||||
}
|
||||
group, e := optionalScalar(raw, 18)
|
||||
if e != nil {
|
||||
return d, e
|
||||
}
|
||||
d.MapGroups[id] = group
|
||||
}
|
||||
return d, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// NPCShopDesign is the ordinary in-game shop, distinct from CashShopTable.
|
||||
type NPCShopDesign struct {
|
||||
Shops map[uint64]NPCShop
|
||||
Products map[uint64]map[uint64]NPCProduct
|
||||
Sell map[uint64]NPCProduct
|
||||
ShopNPCs map[uint64][]uint64
|
||||
}
|
||||
type NPCShop struct{ ID, PackID, ResetType, ResetCount, StartDay uint64 }
|
||||
type NPCProduct struct {
|
||||
ID, GroupID, MaxCount, Discount, Premium, NoBargain, Reputation uint64
|
||||
Reward, Price Reward
|
||||
PremiumPriceType, HighPremium, HighDay, HighShop uint64
|
||||
}
|
||||
|
||||
// ShopInfo.GetShopSellProducts reads only Food and Resource inventory rows.
|
||||
// SellItemTable also has equipment prices; those definitions do not create
|
||||
// an ordinary ShopSell equipment entry point in the current client.
|
||||
func (p NPCProduct) InventorySellable() bool {
|
||||
return p.Reward.Type == 5 || p.Reward.Type == 8
|
||||
}
|
||||
|
||||
func LoadNPCShopDesign(root, version string) (NPCShopDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return NPCShopDesign{}, err
|
||||
}
|
||||
defer closeDB()
|
||||
d, e := loadNPCShopDesign(db)
|
||||
if e != nil {
|
||||
return d, e
|
||||
}
|
||||
d.ShopNPCs = map[uint64][]uint64{}
|
||||
packs := map[uint64]bool{}
|
||||
for _, shop := range d.Shops {
|
||||
packs[shop.PackID] = true
|
||||
}
|
||||
ids := []uint64{}
|
||||
for pack := range packs {
|
||||
ids = append(ids, pack)
|
||||
}
|
||||
slices.Sort(ids)
|
||||
for _, pack := range ids {
|
||||
bindings, e := LoadNPCShopActors(root, version, int(pack))
|
||||
if e != nil {
|
||||
return d, e
|
||||
}
|
||||
for shop, actors := range bindings {
|
||||
if def, ok := d.Shops[shop]; ok && def.PackID == pack {
|
||||
d.ShopNPCs[shop] = actors
|
||||
}
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// NPCInfo pairs FieldNpcTable.InteractionList (9) and InteractionValue (12).
|
||||
// EInteractionType.Shop=3 identifies the real shop ID, rather than deriving an
|
||||
// NPC identity from shop numbering or using one global bargaining discount.
|
||||
func LoadNPCShopActors(root, version string, pack int) (map[uint64][]uint64, error) {
|
||||
db, done, e := openPackDatabase(root, version, pack)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer done()
|
||||
rows, e := db.Query("SELECT id,ProtoBuf FROM FieldNpcTable ORDER BY id")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer func() { _ = 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
|
||||
}
|
||||
ts, e := packedInts(raw, 9)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
vs, e := packedInts(raw, 12)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
for i, t := range ts {
|
||||
if t != 3 {
|
||||
continue
|
||||
}
|
||||
if i >= len(vs) || vs[i] == 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid NPC shop interaction")
|
||||
}
|
||||
out[vs[i]] = append(out[vs[i]], id)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
func loadNPCShopDesign(db *sql.DB) (NPCShopDesign, error) {
|
||||
d := NPCShopDesign{Shops: map[uint64]NPCShop{}, Products: map[uint64]map[uint64]NPCProduct{}, Sell: map[uint64]NPCProduct{}}
|
||||
err := friendshipRows(db, "SELECT id,ProtoBuf FROM ShopTable ORDER BY id", func(id uint64, p []byte) error {
|
||||
r := NPCShop{}
|
||||
for i, dst := range []*uint64{&r.ID, &r.PackID, &r.ResetType, &r.ResetCount, &r.StartDay} {
|
||||
v, e := friendshipScalar(p, []int{5, 8, 12, 11, 14}[i])
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
*dst = v
|
||||
}
|
||||
if r.ID != id || id == 0 || r.ResetType > 3 {
|
||||
return fmt.Errorf("gamedata: invalid shop %d", id)
|
||||
}
|
||||
d.Shops[id] = r
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
err = friendshipRows(db, "SELECT id,ProtoBuf FROM ProductTable ORDER BY groupId,id", func(id uint64, p []byte) error {
|
||||
r := NPCProduct{}
|
||||
for i, dst := range []*uint64{&r.ID, &r.GroupID, &r.MaxCount, &r.Discount, &r.Premium, &r.NoBargain, &r.Reputation, &r.Reward.Type, &r.Reward.ID, &r.Reward.Count, &r.Price.Type, &r.Price.ID, &r.Price.Count} {
|
||||
v, e := friendshipScalar(p, []int{7, 6, 1, 2, 9, 8, 13, 5, 4, 3, 12, 11, 10}[i])
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
*dst = v
|
||||
}
|
||||
if r.ID != id || r.Reward.Count == 0 || r.Price.Count == 0 || r.Discount > 100 {
|
||||
return fmt.Errorf("gamedata: invalid shop product %d", id)
|
||||
}
|
||||
if _, ok := d.Shops[r.GroupID]; !ok {
|
||||
return fmt.Errorf("gamedata: product missing shop %d", r.GroupID)
|
||||
}
|
||||
if d.Products[r.GroupID] == nil {
|
||||
d.Products[r.GroupID] = map[uint64]NPCProduct{}
|
||||
}
|
||||
d.Products[r.GroupID][id] = r
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
sellKeys := map[[2]uint64]bool{}
|
||||
err = friendshipRows(db, "SELECT id,ProtoBuf FROM SellItemTable ORDER BY id", func(id uint64, p []byte) error {
|
||||
r := NPCProduct{}
|
||||
for i, dst := range []*uint64{&r.ID, &r.Discount, &r.Premium, &r.Reward.Type, &r.Reward.ID, &r.Reward.Count, &r.Price.Type, &r.Price.ID, &r.Price.Count, &r.PremiumPriceType, &r.HighPremium, &r.HighDay, &r.HighShop} {
|
||||
v, e := friendshipScalar(p, []int{8, 1, 10, 4, 3, 2, 13, 12, 11, 9, 6, 5, 7}[i])
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
*dst = v
|
||||
}
|
||||
if id == 0 || r.ID != id || r.Reward.ID == 0 || r.Reward.Count != 1 || r.Price.Type == 0 || r.Price.Count == 0 || r.Discount > 100 {
|
||||
return fmt.Errorf("gamedata: invalid sell product %d", id)
|
||||
}
|
||||
k := [2]uint64{r.Reward.Type, r.Reward.ID}
|
||||
if sellKeys[k] {
|
||||
return fmt.Errorf("gamedata: ambiguous sell definition %d:%d", k[0], k[1])
|
||||
}
|
||||
sellKeys[k] = true
|
||||
d.Sell[id] = r
|
||||
return nil
|
||||
})
|
||||
return d, err
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type SkyWayOverwhelmRule struct {
|
||||
Group, Map, APType, Boss, BossAP uint64
|
||||
Monsters, AP []uint64
|
||||
}
|
||||
|
||||
func LoadSkyWayOverwhelm(root, version string) ([]SkyWayOverwhelmRule, error) {
|
||||
db, done, e := openStatDatabase(root, version)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer done()
|
||||
rows, e := db.Query("SELECT ProtoBuf FROM SkyWayFieldTable")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []SkyWayOverwhelmRule
|
||||
for rows.Next() {
|
||||
var b []byte
|
||||
if e = rows.Scan(&b); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
r := SkyWayOverwhelmRule{}
|
||||
for f, p := range map[int]*uint64{10: &r.Group, 13: &r.Map, 1: &r.APType, 4: &r.Boss, 3: &r.BossAP} {
|
||||
*p, e = optionalScalar(b, f)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
r.Monsters, e = packedInts(b, 15)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
r.AP, e = packedInts(b, 14)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if len(r.Monsters) != len(r.AP) {
|
||||
return nil, fmt.Errorf("gamedata: malformed skyway AP mapping")
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type OverwhelmQuestRule struct {
|
||||
Type, Count uint64
|
||||
Targets []uint64
|
||||
Enemies map[uint64][]uint64
|
||||
}
|
||||
|
||||
func LoadOverwhelmQuest(root, version string, pack, quest int) (OverwhelmQuestRule, error) {
|
||||
r := OverwhelmQuestRule{Enemies: map[uint64][]uint64{}}
|
||||
db, done, e := openStatDatabase(root, version)
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
defer done()
|
||||
var b []byte
|
||||
if e = db.QueryRow(fmt.Sprintf("SELECT ProtoBuf FROM QuestTable%d WHERE id=?", pack), quest).Scan(&b); e != nil {
|
||||
return r, e
|
||||
}
|
||||
r.Type, e = optionalScalar(b, 11)
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
r.Count, e = optionalScalar(b, 10)
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
r.Targets, e = packedInts(b, 33)
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
packdb, release, e := openPackDatabase(root, version, pack)
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
defer release()
|
||||
rows, e := packdb.Query("SELECT id,ProtoBuf FROM BattleDeckTable")
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if e = rows.Scan(&id, &raw); e != nil {
|
||||
return r, e
|
||||
}
|
||||
ids, e := packedInts(raw, 14)
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
r.Enemies[id] = ids
|
||||
}
|
||||
return r, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// PackDetailDesign identifies the monsters whose state is consumed by the
|
||||
// client's PackMapRewardInfo. Ordinary story battle rewards are excluded.
|
||||
type PackDetailDesign struct{ RegenMonsterIDs, MapIDs []int }
|
||||
|
||||
func LoadPackDetailDesign(root, version string, packID int) (PackDetailDesign, error) {
|
||||
if packID <= 0 {
|
||||
return PackDetailDesign{}, fmt.Errorf("gamedata: invalid detail pack %d", packID)
|
||||
}
|
||||
db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", packID))
|
||||
if err != nil {
|
||||
return PackDetailDesign{}, err
|
||||
}
|
||||
defer release()
|
||||
design, err := loadPackDetailDesign(db)
|
||||
if err != nil {
|
||||
return design, err
|
||||
}
|
||||
placements, err := LoadFieldMonsterMaps(root, version, packID)
|
||||
if err != nil {
|
||||
return design, err
|
||||
}
|
||||
placed := design.RegenMonsterIDs[:0]
|
||||
for _, id := range design.RegenMonsterIDs {
|
||||
if len(placements[id]) > 0 {
|
||||
placed = append(placed, id)
|
||||
}
|
||||
}
|
||||
design.RegenMonsterIDs = placed
|
||||
common, closeCommon, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return design, err
|
||||
}
|
||||
defer closeCommon()
|
||||
rows, err := common.Query("SELECT id FROM MapTable WHERE packId=? ORDER BY id", packID)
|
||||
if err != nil {
|
||||
return design, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var mapID int
|
||||
if err := rows.Scan(&mapID); err != nil {
|
||||
return design, err
|
||||
}
|
||||
design.MapIDs = append(design.MapIDs, mapID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return design, err
|
||||
}
|
||||
return design, nil
|
||||
}
|
||||
|
||||
func loadPackDetailDesign(db *sql.DB) (PackDetailDesign, error) {
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM FieldMonsterTable ORDER BY id")
|
||||
if err != nil {
|
||||
return PackDetailDesign{}, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var design PackDetailDesign
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
return PackDetailDesign{}, err
|
||||
}
|
||||
values := map[int]uint64{}
|
||||
for _, field := range []int{24, 30, 31} {
|
||||
v, err := packedInts(raw, field)
|
||||
if err != nil || len(v) > 1 || id <= 0 {
|
||||
return PackDetailDesign{}, fmt.Errorf("gamedata: invalid detail monster %d field %d", id, field)
|
||||
}
|
||||
if len(v) == 1 {
|
||||
values[field] = v[0]
|
||||
}
|
||||
}
|
||||
if values[31] == 1 && values[30] != 3 && values[24] > 0 {
|
||||
design.RegenMonsterIDs = append(design.RegenMonsterIDs, id)
|
||||
}
|
||||
}
|
||||
return design, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// PackJamDesign is the installed client's insertion animation and reward rule.
|
||||
type PackJamDesign struct {
|
||||
InsertMin, InsertMax uint64
|
||||
Reward Reward
|
||||
}
|
||||
|
||||
func LoadPackJamDesign(root, version string) (*PackJamDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
return loadPackJamDesign(db)
|
||||
}
|
||||
|
||||
func loadPackJamDesign(db *sql.DB) (*PackJamDesign, error) {
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM PackJamEventTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var design *PackJamDesign
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id != 0 || design != nil {
|
||||
return nil, fmt.Errorf("gamedata: unsupported pack jam rule %d", id)
|
||||
}
|
||||
values := map[int]uint64{}
|
||||
for field := 1; field <= 6; field++ {
|
||||
v, err := packedInts(raw, field)
|
||||
if err != nil || len(v) > 1 {
|
||||
return nil, fmt.Errorf("gamedata: invalid pack jam field %d", field)
|
||||
}
|
||||
if len(v) == 1 {
|
||||
values[field] = v[0]
|
||||
}
|
||||
}
|
||||
if values[1] != 0 || values[3] == 0 || values[2] < values[3] || values[2] > uint64(^uint32(0)>>1) || values[4] == 0 || values[4] > uint64(^uint32(0)>>1) || !packJamCurrency(values[6]) || values[5] != 0 {
|
||||
return nil, fmt.Errorf("gamedata: unsupported pack jam reward or insertion rule")
|
||||
}
|
||||
design = &PackJamDesign{InsertMin: values[3], InsertMax: values[2], Reward: Reward{Count: values[4], ID: values[5], Type: values[6]}}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if design == nil {
|
||||
return nil, fmt.Errorf("gamedata: missing pack jam rule")
|
||||
}
|
||||
return design, nil
|
||||
}
|
||||
|
||||
func packJamCurrency(typ uint64) bool {
|
||||
switch typ {
|
||||
case 2, 3, 4, 12, 20:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d PackJamDesign) ValidateReward() error {
|
||||
if !packJamCurrency(d.Reward.Type) || d.Reward.ID != 0 || d.Reward.Count == 0 || d.Reward.Count > uint64(^uint32(0)>>1) {
|
||||
return fmt.Errorf("gamedata: unsupported pack jam reward")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package gamedata
|
||||
|
||||
// PackRecoveryPolicy keeps the actual PackTable type, including packs outside
|
||||
// the currently playable story catalog.
|
||||
type PackRecoveryPolicy struct{ Types map[int]uint64 }
|
||||
|
||||
func LoadPackRecoveryPolicy(root, version string) (*PackRecoveryPolicy, error) {
|
||||
db, done, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM PackTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
p := &PackRecoveryPolicy{Types: map[int]uint64{}}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var raw []byte
|
||||
if err = rows.Scan(&id, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typ, e := optionalScalar(raw, 55)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
p.Types[id] = typ
|
||||
}
|
||||
return p, rows.Err()
|
||||
}
|
||||
func (p *PackRecoveryPolicy) Allowed(pack int, complete bool) bool {
|
||||
typ, ok := p.Types[pack]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch typ {
|
||||
case 4:
|
||||
return false
|
||||
case 1, 6:
|
||||
return complete
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// LoadPackSummaryTargets follows PackMapRewardInfo.IsSummaryTargetPackType in
|
||||
// the 2.35.10 client. Pack identities and types come from the installed tables.
|
||||
func LoadPackSummaryTargets(root, version string) (map[int]bool, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
return loadPackSummaryTargets(db)
|
||||
}
|
||||
|
||||
func loadPackSummaryTargets(db *sql.DB) (map[int]bool, error) {
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM PackTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
result := map[int]bool{}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
types, err := packedInts(raw, 55)
|
||||
if err != nil || len(types) > 1 || id <= 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid summary pack %d", id)
|
||||
}
|
||||
var packType uint64
|
||||
if len(types) == 1 {
|
||||
packType = types[0]
|
||||
}
|
||||
switch packType {
|
||||
case 2, 3, 5, 7, 8, 9, 10, 12, 13, 14, 100:
|
||||
continue
|
||||
}
|
||||
result[id] = true
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// PackTransition is the authoritative story edge in PackTable. It is kept
|
||||
// separate from quest IDs because each story pack owns its own QuestTable.
|
||||
type PackTransition struct {
|
||||
PackID int
|
||||
NextPackID int
|
||||
}
|
||||
|
||||
func LoadPackTransition(root, version string, packID int) (PackTransition, error) {
|
||||
if packID <= 0 {
|
||||
return PackTransition{}, fmt.Errorf("gamedata: invalid pack transition id %d", packID)
|
||||
}
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return PackTransition{}, err
|
||||
}
|
||||
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)
|
||||
}
|
||||
next, err := packedInts(raw, 45) // PackTable.NextPackId
|
||||
if err != nil || len(next) != 1 || next[0] == 0 {
|
||||
return PackTransition{}, fmt.Errorf("gamedata: pack %d has invalid next-pack edge", packID)
|
||||
}
|
||||
var nextPack []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM PackTable WHERE id=?", next[0]).Scan(&nextPack); err != nil {
|
||||
return PackTransition{}, fmt.Errorf("gamedata: next pack %d: %w", next[0], err)
|
||||
}
|
||||
var questCount int
|
||||
if err := db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM QuestTable%d", next[0])).Scan(&questCount); err != nil || questCount == 0 {
|
||||
return PackTransition{}, fmt.Errorf("gamedata: next pack %d has no quests", next[0])
|
||||
}
|
||||
return PackTransition{PackID: packID, NextPackID: int(next[0])}, nil
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
const (
|
||||
PictorialCharacter = 1
|
||||
PictorialTalent = 2
|
||||
PictorialEquipment = 3
|
||||
PictorialCooking = 4
|
||||
PictorialItem = 5
|
||||
PictorialCostume = 7
|
||||
)
|
||||
|
||||
type PictorialBuff struct {
|
||||
ID uint64
|
||||
Category uint64
|
||||
StatType uint64
|
||||
Value float64
|
||||
}
|
||||
|
||||
type PictorialBuffStat struct {
|
||||
Category uint64
|
||||
StatType uint64
|
||||
Value float64
|
||||
}
|
||||
|
||||
type CharacterPictorial struct {
|
||||
ID uint64
|
||||
UniqueIDs []uint64
|
||||
BuffID uint64
|
||||
}
|
||||
|
||||
type TalentPictorial struct {
|
||||
ID uint64
|
||||
TalentIDs []uint64
|
||||
}
|
||||
|
||||
type CostumePictorial struct {
|
||||
ID uint64
|
||||
CostumeID uint64
|
||||
BuffIDs []uint64
|
||||
Thresholds []uint64
|
||||
}
|
||||
|
||||
type EquipmentPictorial struct {
|
||||
ID uint64
|
||||
EquipmentID uint64
|
||||
BuffID uint64
|
||||
}
|
||||
|
||||
type CookingPictorial struct {
|
||||
ID uint64
|
||||
RecipeID uint64
|
||||
BuffID uint64
|
||||
}
|
||||
|
||||
type ItemPictorial struct {
|
||||
ID uint64
|
||||
ItemID uint64
|
||||
BuffID uint64
|
||||
Category uint64
|
||||
}
|
||||
|
||||
type CharacterPictorialMeta struct {
|
||||
UniqueID uint64
|
||||
TalentID uint64
|
||||
UsePackTemporary bool
|
||||
}
|
||||
|
||||
// PictorialDesign is immutable 2.35.10 design data. Dynamic account state is
|
||||
// deliberately not copied here: callers match their current ownership against
|
||||
// these rows each time they build PictorialBookInfo or AllCharRefresh.
|
||||
type PictorialDesign struct {
|
||||
Root string
|
||||
Version string
|
||||
Characters []CharacterPictorial
|
||||
Talents []TalentPictorial
|
||||
Costumes []CostumePictorial
|
||||
Equipment []EquipmentPictorial
|
||||
Cooking []CookingPictorial
|
||||
Items []ItemPictorial
|
||||
Buffs map[uint64]PictorialBuff
|
||||
CharMeta map[uint64]CharacterPictorialMeta
|
||||
CharStats *CharacterStatDesign
|
||||
}
|
||||
|
||||
func LoadPictorialDesign(root, version string) (*PictorialDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
d := &PictorialDesign{Root: root, Version: version, Buffs: map[uint64]PictorialBuff{}, CharMeta: map[uint64]CharacterPictorialMeta{}}
|
||||
if err := loadProtoRows(db, "CharacterPictorialBookTable", func(proto []byte) error {
|
||||
id, err := exactlyOne(proto, 5)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buff, err := exactlyOne(proto, 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := packedInts(proto, 1)
|
||||
if err != nil || len(ids) == 0 {
|
||||
return fmt.Errorf("gamedata: character pictorial %d ids: %w", id, err)
|
||||
}
|
||||
d.Characters = append(d.Characters, CharacterPictorial{ID: id, UniqueIDs: ids, BuffID: buff})
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadProtoRows(db, "TalentPictorialBookTable", func(proto []byte) error {
|
||||
id, err := exactlyOne(proto, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := packedInts(proto, 2)
|
||||
if err != nil || len(ids) == 0 {
|
||||
return fmt.Errorf("gamedata: talent pictorial %d ids: %w", id, err)
|
||||
}
|
||||
d.Talents = append(d.Talents, TalentPictorial{ID: id, TalentIDs: ids})
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadProtoRows(db, "CostumePictorialBookTable", func(proto []byte) error {
|
||||
id, err := exactlyOne(proto, 5)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
costumeID, err := exactlyOne(proto, 3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffs, err := packedInts(proto, 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
thresholds, err := packedInts(proto, 4)
|
||||
if err != nil || len(buffs) == 0 || len(buffs) != len(thresholds) {
|
||||
return fmt.Errorf("gamedata: costume pictorial %d has mismatched buff thresholds", id)
|
||||
}
|
||||
d.Costumes = append(d.Costumes, CostumePictorial{ID: id, CostumeID: costumeID, BuffIDs: buffs, Thresholds: thresholds})
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadProtoRows(db, "EquipmentPictorialBookTable", func(proto []byte) error {
|
||||
id, err := exactlyOne(proto, 5)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
equipmentID, err := exactlyOne(proto, 4)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buff, err := exactlyOne(proto, 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.Equipment = append(d.Equipment, EquipmentPictorial{ID: id, EquipmentID: equipmentID, BuffID: buff})
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadProtoRows(db, "CookingPictorialBookTable", func(proto []byte) error {
|
||||
buff, err := exactlyOne(proto, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recipe, err := exactlyOne(proto, 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := exactlyOne(proto, 3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.Cooking = append(d.Cooking, CookingPictorial{ID: id, RecipeID: recipe, BuffID: buff})
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadProtoRows(db, "ItemPictorialBookTable", func(proto []byte) error {
|
||||
category, err := zeroOrOne(proto, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buff, err := exactlyOne(proto, 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := exactlyOne(proto, 3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item, err := exactlyOne(proto, 4)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.Items = append(d.Items, ItemPictorial{ID: id, ItemID: item, BuffID: buff, Category: category})
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadProtoRows(db, "AdventuregroupBuffTable", func(proto []byte) error {
|
||||
id, err := exactlyOne(proto, 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stat, err := exactlyOne(proto, 3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
category, err := zeroOrOne(proto, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value, found, err := fixed64Double(proto, 4)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: pictorial buff %d value: %w", id, err)
|
||||
}
|
||||
if !found || value <= 0 {
|
||||
// Many collection-discovery rows have no battle-stat reward.
|
||||
return nil
|
||||
}
|
||||
d.Buffs[id] = PictorialBuff{ID: id, Category: category, StatType: stat, Value: value}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadProtoRows(db, "CharTable", func(proto []byte) error {
|
||||
id, err := exactlyOne(proto, 12)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uniqueID, err := exactlyOne(proto, 20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
talentID, err := zeroOrOne(proto, 18)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporary, err := zeroOrOne(proto, 21)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.CharMeta[id] = CharacterPictorialMeta{UniqueID: uniqueID, TalentID: talentID, UsePackTemporary: temporary != 0}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.CharStats, err = loadCharacterStatDesign(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(d.Characters, func(i, j int) bool { return d.Characters[i].ID < d.Characters[j].ID })
|
||||
sort.Slice(d.Talents, func(i, j int) bool { return d.Talents[i].ID < d.Talents[j].ID })
|
||||
sort.Slice(d.Costumes, func(i, j int) bool { return d.Costumes[i].ID < d.Costumes[j].ID })
|
||||
sort.Slice(d.Equipment, func(i, j int) bool { return d.Equipment[i].ID < d.Equipment[j].ID })
|
||||
sort.Slice(d.Cooking, func(i, j int) bool { return d.Cooking[i].ID < d.Cooking[j].ID })
|
||||
sort.Slice(d.Items, func(i, j int) bool { return d.Items[i].ID < d.Items[j].ID })
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func loadProtoRows(db *sql.DB, table string, consume func([]byte) error) error {
|
||||
rows, err := db.Query("SELECT ProtoBuf FROM " + table)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: query %s: %w", table, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var proto []byte
|
||||
if err := rows.Scan(&proto); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := consume(proto); err != nil {
|
||||
return fmt.Errorf("gamedata: decode %s: %w", table, err)
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func exactlyOne(proto []byte, field int) (uint64, error) {
|
||||
values, err := packedInts(proto, field)
|
||||
if err != nil || len(values) != 1 {
|
||||
return 0, fmt.Errorf("field %d values %v: %w", field, values, err)
|
||||
}
|
||||
return values[0], nil
|
||||
}
|
||||
|
||||
func zeroOrOne(proto []byte, field int) (uint64, error) {
|
||||
values, err := packedInts(proto, field)
|
||||
if err != nil || len(values) > 1 {
|
||||
return 0, fmt.Errorf("field %d values %v: %w", field, values, err)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return values[0], nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// PresetDesign is the normal party preset configuration in GameDefaultTable.
|
||||
type PresetDesign struct {
|
||||
BaseCount, Maximum, PriceType, PriceID, Price uint64
|
||||
Icons map[uint64]bool
|
||||
}
|
||||
|
||||
func (d PresetDesign) Validate() error {
|
||||
if d.BaseCount == 0 || d.Maximum < d.BaseCount || d.Price == 0 || d.PriceID != 0 {
|
||||
return fmt.Errorf("gamedata: invalid preset configuration")
|
||||
}
|
||||
switch d.PriceType {
|
||||
case 2, 3, 4, 12:
|
||||
default:
|
||||
return fmt.Errorf("gamedata: unsupported preset currency %d", d.PriceType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoadPresetDesign(root, version string) (*PresetDesign, error) {
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
return loadPresetDesign(db)
|
||||
}
|
||||
|
||||
func loadPresetDesign(db *sql.DB) (*PresetDesign, error) {
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := &PresetDesign{}
|
||||
for field, target := range map[int]*uint64{91: &d.BaseCount, 92: &d.Price, 93: &d.PriceID, 94: &d.PriceType, 95: &d.Maximum} {
|
||||
v, err := optionalScalar(raw, field)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
*target = v
|
||||
}
|
||||
if err := d.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := db.Query("SELECT id FROM PresetTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
d.Icons = map[uint64]bool{}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Icons[id] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"maps"
|
||||
)
|
||||
|
||||
// PromotionCost is one exact CharGrowthTable.ClassupItem{Type,Id,Count} row.
|
||||
// Currency (type 4) has no inventory index; the other rows are owned items.
|
||||
type PromotionCost struct{ Type, ID, Count uint64 }
|
||||
|
||||
// PromotionGrowthResult is the authoritative result of one CharGrowth request
|
||||
// that can cross one or more class-up boundaries. Costs contains the exact
|
||||
// cumulative class-up costs selected by the request's aggregate gold amount.
|
||||
type PromotionGrowthResult struct {
|
||||
CharacterID uint64
|
||||
Level uint64
|
||||
Exp uint64
|
||||
Costs []PromotionCost
|
||||
Refunds []GrowthMaterial
|
||||
}
|
||||
|
||||
// CharacterPromotion reads the installed character and growth tables. At a
|
||||
// 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) {
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer release()
|
||||
return characterPromotion(db, charID, level, exp)
|
||||
}
|
||||
|
||||
// CharacterGrowthPromotions applies a client's combined class-up/growth
|
||||
// request. The UI aggregates gold and class-up resources for every boundary
|
||||
// crossed by the selected target level, while ordinary EXP resources remain
|
||||
// in the same request. Gold therefore identifies an exact prefix of the
|
||||
// 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) {
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
defer release()
|
||||
return characterGrowthPromotions(db, charID, level, exp, submitted)
|
||||
}
|
||||
|
||||
type promotionStage struct {
|
||||
fromID, nextID uint64
|
||||
maximum uint64
|
||||
costs []PromotionCost
|
||||
}
|
||||
|
||||
func characterGrowthPromotions(db *sql.DB, charID int, level, exp uint64, submitted []PromotionCost) (PromotionGrowthResult, error) {
|
||||
if charID <= 0 || level == 0 || len(submitted) == 0 {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: invalid combined promotion input")
|
||||
}
|
||||
requested := make(map[[2]uint64]uint64, len(submitted))
|
||||
var requestedGold uint64
|
||||
for _, cost := range submitted {
|
||||
if cost.Count == 0 || (cost.Type != 4 && (cost.Type != 8 || cost.ID == 0)) {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: invalid submitted promotion material")
|
||||
}
|
||||
key := [2]uint64{cost.Type, cost.ID}
|
||||
if cost.Count > ^uint64(0)-requested[key] {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: submitted promotion material overflow")
|
||||
}
|
||||
requested[key] += cost.Count
|
||||
if cost.Type == 4 {
|
||||
if cost.ID != 0 || requestedGold != 0 {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: invalid submitted promotion currency")
|
||||
}
|
||||
requestedGold = cost.Count
|
||||
}
|
||||
}
|
||||
if requestedGold == 0 {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: combined promotion has no gold")
|
||||
}
|
||||
|
||||
currentID := uint64(charID)
|
||||
var stages []promotionStage
|
||||
var cumulative []PromotionCost
|
||||
var cumulativeGold uint64
|
||||
for len(stages) < 32 {
|
||||
nextID, costs, maximum, err := promotionDefinition(db, currentID)
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
stages = append(stages, promotionStage{fromID: currentID, nextID: nextID, maximum: maximum, costs: costs})
|
||||
cumulative = append(cumulative, costs...)
|
||||
for _, cost := range costs {
|
||||
if cost.Type == 4 {
|
||||
if cost.Count > ^uint64(0)-cumulativeGold {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: cumulative promotion gold overflow")
|
||||
}
|
||||
cumulativeGold += cost.Count
|
||||
}
|
||||
}
|
||||
if cumulativeGold == requestedGold {
|
||||
break
|
||||
}
|
||||
if cumulativeGold > requestedGold {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: submitted gold %d does not match a promotion prefix", requestedGold)
|
||||
}
|
||||
currentID = nextID
|
||||
}
|
||||
if cumulativeGold != requestedGold {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: submitted gold %d exceeds the promotion chain", requestedGold)
|
||||
}
|
||||
|
||||
remaining := make(map[[2]uint64]uint64, len(requested))
|
||||
maps.Copy(remaining, requested)
|
||||
for _, cost := range cumulative {
|
||||
key := [2]uint64{cost.Type, cost.ID}
|
||||
if remaining[key] < cost.Count {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: missing cumulative promotion cost %d/%d x%d", cost.Type, cost.ID, cost.Count-remaining[key])
|
||||
}
|
||||
remaining[key] -= cost.Count
|
||||
}
|
||||
if remaining[[2]uint64{4, 0}] != 0 {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: unexpected promotion gold remainder")
|
||||
}
|
||||
|
||||
var gained uint64
|
||||
for key, count := range remaining {
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
if key[0] != 8 || key[1] == 0 {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: unexpected combined promotion material %d/%d x%d", key[0], key[1], count)
|
||||
}
|
||||
var proto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM ResourceTable WHERE id=?", key[1]).Scan(&proto); err != nil {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: promotion growth resource %d: %w", key[1], err)
|
||||
}
|
||||
values, err := packedInts(proto, 9)
|
||||
if err != nil || len(values) != 1 || values[0] == 0 {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: resource %d is not an experience material", key[1])
|
||||
}
|
||||
if count > (^uint64(0)-gained)/values[0] {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: combined promotion experience overflow")
|
||||
}
|
||||
gained += count * values[0]
|
||||
}
|
||||
|
||||
currentID = uint64(charID)
|
||||
currentLevel := level
|
||||
currentExp := exp
|
||||
for _, stage := range stages {
|
||||
if currentID != stage.fromID {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: invalid promotion stage sequence")
|
||||
}
|
||||
definition, err := growthDefinition(db, currentID)
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
currentLevel, currentExp, gained, err = applyGrowthExperience(db, definition, currentLevel, currentExp, gained)
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
if currentLevel != stage.maximum {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: insufficient experience to reach promotion cap %d", definition.maximum)
|
||||
}
|
||||
currentID = stage.nextID
|
||||
}
|
||||
finalDefinition, err := growthDefinition(db, currentID)
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
currentLevel, currentExp, gained, err = applyGrowthExperience(db, finalDefinition, currentLevel, currentExp, gained)
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
refunds, err := refundGrowthResources(db, gained)
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
return PromotionGrowthResult{CharacterID: currentID, Level: currentLevel, Exp: currentExp, Costs: cumulative, Refunds: refunds}, nil
|
||||
}
|
||||
|
||||
type characterGrowthDefinition struct {
|
||||
group, maximum uint64
|
||||
}
|
||||
|
||||
func growthDefinition(db *sql.DB, charID uint64) (characterGrowthDefinition, error) {
|
||||
var charProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(&charProto); err != nil {
|
||||
return characterGrowthDefinition{}, fmt.Errorf("gamedata: character %d: %w", charID, err)
|
||||
}
|
||||
growthIDs, _ := packedInts(charProto, 1)
|
||||
if len(growthIDs) != 1 {
|
||||
return characterGrowthDefinition{}, fmt.Errorf("gamedata: character %d has no growth definition", charID)
|
||||
}
|
||||
var growthProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthIDs[0]).Scan(&growthProto); err != nil {
|
||||
return characterGrowthDefinition{}, err
|
||||
}
|
||||
groups, _ := packedInts(growthProto, 1)
|
||||
maximum, _ := packedInts(growthProto, 9)
|
||||
if len(groups) != 1 || len(maximum) != 1 {
|
||||
return characterGrowthDefinition{}, fmt.Errorf("gamedata: invalid growth definition %d", growthIDs[0])
|
||||
}
|
||||
return characterGrowthDefinition{group: groups[0], maximum: maximum[0]}, nil
|
||||
}
|
||||
|
||||
func promotionDefinition(db *sql.DB, charID uint64) (uint64, []PromotionCost, uint64, error) {
|
||||
definition, err := growthDefinition(db, charID)
|
||||
if err != nil {
|
||||
return 0, nil, 0, err
|
||||
}
|
||||
var charProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(&charProto); err != nil {
|
||||
return 0, nil, 0, err
|
||||
}
|
||||
next, _ := packedInts(charProto, 15)
|
||||
if len(next) != 1 || next[0] == 0 {
|
||||
return 0, nil, 0, fmt.Errorf("gamedata: character %d has no next stage", charID)
|
||||
}
|
||||
growthIDs, _ := packedInts(charProto, 1)
|
||||
var growthProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthIDs[0]).Scan(&growthProto); err != nil {
|
||||
return 0, nil, 0, err
|
||||
}
|
||||
types, _ := packedInts(growthProto, 4)
|
||||
ids, _ := packedInts(growthProto, 3)
|
||||
counts, _ := packedInts(growthProto, 2)
|
||||
if len(types) == 0 || len(types) != len(ids) || len(types) != len(counts) {
|
||||
return 0, nil, 0, fmt.Errorf("gamedata: invalid promotion costs for character %d", charID)
|
||||
}
|
||||
costs := make([]PromotionCost, len(types))
|
||||
for i := range costs {
|
||||
costs[i] = PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]}
|
||||
}
|
||||
return next[0], costs, definition.maximum, nil
|
||||
}
|
||||
|
||||
func applyGrowthExperience(db *sql.DB, definition characterGrowthDefinition, level, exp, gained uint64) (uint64, uint64, uint64, error) {
|
||||
if level > definition.maximum {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: level %d exceeds growth maximum %d", level, definition.maximum)
|
||||
}
|
||||
for gained > 0 && level < definition.maximum {
|
||||
var proto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharLevelTable WHERE GroupId=? AND id=?", definition.group, level).Scan(&proto); err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
required, _ := packedInts(proto, 8)
|
||||
if len(required) != 1 || required[0] == 0 || exp >= required[0] {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: invalid level curve group %d level %d", definition.group, level)
|
||||
}
|
||||
need := required[0] - exp
|
||||
if gained < need {
|
||||
exp += gained
|
||||
gained = 0
|
||||
break
|
||||
}
|
||||
gained -= need
|
||||
level++
|
||||
exp = 0
|
||||
}
|
||||
if level == definition.maximum {
|
||||
exp = 0
|
||||
}
|
||||
return level, exp, gained, nil
|
||||
}
|
||||
|
||||
func characterPromotion(db *sql.DB, charID int, level, exp uint64) (uint64, []PromotionCost, error) {
|
||||
if charID <= 0 || level == 0 {
|
||||
return 0, nil, fmt.Errorf("gamedata: invalid promotion character or level")
|
||||
}
|
||||
var current []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(¤t); err != nil {
|
||||
return 0, nil, fmt.Errorf("gamedata: promotion character %d: %w", charID, err)
|
||||
}
|
||||
next, err := packedInts(current, 15)
|
||||
if err != nil || len(next) != 1 || next[0] == 0 {
|
||||
return 0, nil, fmt.Errorf("gamedata: character %d has no next stage", charID)
|
||||
}
|
||||
growthID, err := packedInts(current, 1)
|
||||
if err != nil || len(growthID) != 1 {
|
||||
return 0, nil, fmt.Errorf("gamedata: character %d has no growth definition", charID)
|
||||
}
|
||||
var growth []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthID[0]).Scan(&growth); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
maximum, _ := packedInts(growth, 9)
|
||||
group, _ := packedInts(growth, 1)
|
||||
if len(maximum) != 1 || len(group) != 1 || level != maximum[0] {
|
||||
return 0, nil, fmt.Errorf("gamedata: character %d has not reached its promotion level", charID)
|
||||
}
|
||||
var curve []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharLevelTable WHERE GroupId=? AND id=?", group[0], level).Scan(&curve); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
required, _ := packedInts(curve, 8)
|
||||
if len(required) != 0 && exp < required[0] {
|
||||
return 0, nil, fmt.Errorf("gamedata: character %d has not reached its promotion experience", charID)
|
||||
}
|
||||
var nextProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", next[0]).Scan(&nextProto); err != nil {
|
||||
return 0, nil, fmt.Errorf("gamedata: next character %d: %w", next[0], err)
|
||||
}
|
||||
nextGrowth, _ := packedInts(nextProto, 1)
|
||||
if len(nextGrowth) != 1 || nextGrowth[0] == growthID[0] {
|
||||
return 0, nil, fmt.Errorf("gamedata: invalid promotion %d -> %d", charID, next[0])
|
||||
}
|
||||
types, _ := packedInts(growth, 4)
|
||||
ids, _ := packedInts(growth, 3)
|
||||
counts, _ := packedInts(growth, 2)
|
||||
if len(types) == 0 || len(types) != len(ids) || len(types) != len(counts) {
|
||||
return 0, nil, fmt.Errorf("gamedata: invalid promotion costs for character %d", charID)
|
||||
}
|
||||
costs := make([]PromotionCost, len(types))
|
||||
for i := range costs {
|
||||
if counts[i] == 0 || (types[i] != 4 && ids[i] == 0) {
|
||||
return 0, nil, fmt.Errorf("gamedata: invalid promotion cost for character %d", charID)
|
||||
}
|
||||
costs[i] = PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]}
|
||||
}
|
||||
return next[0], costs, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package gamedata
|
||||
|
||||
// LoadQuestCostumes loads only costume rewards referenced by the installed
|
||||
// quest catalog, using the same character and duplicate-copy metadata as gacha.
|
||||
func LoadQuestCostumes(root, version string, packs map[int]map[int]QuestDesign, story *StoryCatalog) (*RegularGachaCatalog, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
catalog := &RegularGachaCatalog{characters: map[uint64]CharacterDesign{}}
|
||||
if story != nil {
|
||||
for _, pack := range story.Packs {
|
||||
for _, reward := range pack.BuyRewards {
|
||||
if reward.Type == 11 {
|
||||
design, err := loadGachaCharacterDesign(db, reward.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
catalog.characters[reward.ID] = design
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, quests := range packs {
|
||||
for _, quest := range quests {
|
||||
for _, slot := range quest.Rewards {
|
||||
for _, reward := range slot {
|
||||
if reward.Type != 11 {
|
||||
continue
|
||||
}
|
||||
if _, exists := catalog.characters[reward.ID]; exists {
|
||||
continue
|
||||
}
|
||||
design, err := loadGachaCharacterDesign(db, reward.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
catalog.characters[reward.ID] = design
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return catalog, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
return loadQuestDifficultiesDB(db)
|
||||
}
|
||||
func loadQuestDifficultiesDB(db *sql.DB) (map[int]map[int]bool, error) {
|
||||
rows, err := db.Query("SELECT groupId,id FROM QuestDifficultyTable ORDER BY groupId,id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
result := map[int]map[int]bool{}
|
||||
for rows.Next() {
|
||||
var pack, level int
|
||||
if err := rows.Scan(&pack, &level); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pack <= 0 || level < 1 || level > 4 {
|
||||
return nil, fmt.Errorf("gamedata: invalid difficulty pack%d level%d", pack, level)
|
||||
}
|
||||
if result[pack] == nil {
|
||||
result[pack] = map[int]bool{0: true}
|
||||
}
|
||||
result[pack][level] = true
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// QuestFormation is the static party design attached to a quest. The IDs in
|
||||
// this structure are GameData design IDs, not player inventory indices.
|
||||
// Runtime code must resolve them against the current account before emitting
|
||||
// DeckDBInfo.
|
||||
type QuestFormation struct {
|
||||
QuestID uint64
|
||||
CharGroupID uint64
|
||||
StoryCharGroupID uint64
|
||||
DeckList []uint64
|
||||
Characters []QuestCharacterDesign
|
||||
StoryCostumes []QuestCostumeDesign
|
||||
}
|
||||
|
||||
// QuestCharacterDesign is one row from CharGroupTable. Order is the row's Id
|
||||
// inside the group and Level is the story-provided level.
|
||||
type QuestCharacterDesign struct {
|
||||
Order uint64
|
||||
CharacterID uint64
|
||||
Level uint64
|
||||
Banned bool
|
||||
}
|
||||
|
||||
// QuestCostumeDesign is one row from StoryCharGroupTable. UniqueCharacterID
|
||||
// comes from CostumeTable.UseUniqueCharId and is useful for matching a story
|
||||
// costume to its character design. Versioned world configuration identifies
|
||||
// player-controlled placeholders; this loader preserves their authored IDs.
|
||||
type QuestCostumeDesign struct {
|
||||
Order uint64
|
||||
CostumeID uint64
|
||||
UniqueCharacterID uint64
|
||||
}
|
||||
|
||||
// LoadQuestFormations reads all static formation definitions for one pack
|
||||
// from the authoritative shared quest database. It intentionally does not
|
||||
// invent player inventory indices or positions for an empty DeckList.
|
||||
func LoadQuestFormations(root, version string, packID int) (map[int]QuestFormation, error) {
|
||||
if packID <= 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid quest pack %d", packID)
|
||||
}
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
return loadQuestFormationsDB(db, packID)
|
||||
}
|
||||
|
||||
func loadQuestFormationsDB(db *sql.DB, packID int) (map[int]QuestFormation, error) {
|
||||
if db == nil || packID <= 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid quest formation source")
|
||||
}
|
||||
rows, err := db.Query(fmt.Sprintf("SELECT id,ProtoBuf FROM QuestTable%d ORDER BY id", packID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: query QuestTable%d formations: %w", packID, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
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 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)
|
||||
}
|
||||
deckList, err := packedInts(proto, 12)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: quest %d deck list: %w", questID, err)
|
||||
}
|
||||
storyGroupID, err := optionalScalar(proto, 61)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: quest %d story group: %w", questID, err)
|
||||
}
|
||||
formation := QuestFormation{
|
||||
QuestID: uint64(questID),
|
||||
CharGroupID: charGroupID,
|
||||
StoryCharGroupID: storyGroupID,
|
||||
DeckList: append([]uint64(nil), deckList...),
|
||||
}
|
||||
if charGroupID != 0 {
|
||||
group, ok := charGroups[charGroupID]
|
||||
if !ok {
|
||||
group, err = loadCharacterGroup(db, charGroupID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: quest %d: %w", questID, err)
|
||||
}
|
||||
charGroups[charGroupID] = group
|
||||
}
|
||||
formation.Characters = append([]QuestCharacterDesign(nil), group...)
|
||||
}
|
||||
if storyGroupID != 0 {
|
||||
group, ok := storyGroups[storyGroupID]
|
||||
if !ok {
|
||||
group, err = loadStoryCostumeGroup(db, storyGroupID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: quest %d: %w", questID, err)
|
||||
}
|
||||
storyGroups[storyGroupID] = group
|
||||
}
|
||||
formation.StoryCostumes = append([]QuestCostumeDesign(nil), group...)
|
||||
}
|
||||
formations[questID] = formation
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("gamedata: iterate QuestTable%d formations: %w", packID, err)
|
||||
}
|
||||
return formations, nil
|
||||
}
|
||||
|
||||
func loadCharacterGroup(db *sql.DB, groupID uint64) ([]QuestCharacterDesign, error) {
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM CharGroupTable WHERE GroupId=? ORDER BY id", groupID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query character group %d: %w", groupID, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var result []QuestCharacterDesign
|
||||
for rows.Next() {
|
||||
var rowID int
|
||||
var proto []byte
|
||||
if err := rows.Scan(&rowID, &proto); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
characterID, err := requiredScalar(proto, 1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("character group %d row %d: %w", groupID, rowID, err)
|
||||
}
|
||||
order, err := requiredScalar(proto, 3)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("character group %d row %d: %w", groupID, rowID, err)
|
||||
}
|
||||
banned, err := optionalScalar(proto, 4)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("character group %d row %d banned: %w", groupID, rowID, err)
|
||||
}
|
||||
level, err := requiredScalar(proto, 5)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("character group %d row %d level: %w", groupID, rowID, err)
|
||||
}
|
||||
result = append(result, QuestCharacterDesign{Order: order, CharacterID: characterID, Level: level, Banned: banned != 0})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, fmt.Errorf("character group %d is empty", groupID)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadStoryCostumeGroup(db *sql.DB, groupID uint64) ([]QuestCostumeDesign, error) {
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM StoryCharGroupTable WHERE GroupId=? ORDER BY id", groupID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query story costume group %d: %w", groupID, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var result []QuestCostumeDesign
|
||||
type storyRow struct {
|
||||
id int
|
||||
proto []byte
|
||||
}
|
||||
var sourceRows []storyRow
|
||||
for rows.Next() {
|
||||
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)
|
||||
}
|
||||
order, err := requiredScalar(proto, 3)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("story costume group %d row %d: %w", groupID, rowID, err)
|
||||
}
|
||||
var costumeProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CostumeTable WHERE id=?", costumeID).Scan(&costumeProto); err != nil {
|
||||
return nil, fmt.Errorf("story costume %d: %w", costumeID, err)
|
||||
}
|
||||
uniqueCharacterID, err := requiredScalar(costumeProto, 27)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("story costume %d unique character: %w", costumeID, err)
|
||||
}
|
||||
result = append(result, QuestCostumeDesign{Order: order, CostumeID: costumeID, UniqueCharacterID: uniqueCharacterID})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, fmt.Errorf("story costume group %d is empty", groupID)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func optionalScalar(proto []byte, field int) (uint64, error) {
|
||||
values, err := packedInts(proto, field)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if len(values) != 1 {
|
||||
return 0, fmt.Errorf("field %d has %d scalar values", field, len(values))
|
||||
}
|
||||
return values[0], nil
|
||||
}
|
||||
|
||||
func requiredScalar(proto []byte, field int) (uint64, error) {
|
||||
value, err := optionalScalar(proto, field)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if value == 0 {
|
||||
return 0, fmt.Errorf("field %d is missing or zero", field)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// QuestDesign is the authoritative reward definition from QuestTable<pack>.
|
||||
// Reward slot zero is normal story difficulty; later slots are separate
|
||||
// difficulty tiers and must never be added to the same clear.
|
||||
type QuestDesign struct {
|
||||
ID int
|
||||
Type int
|
||||
PriorQuestID int
|
||||
NextQuestID int
|
||||
GiveQuestItemIDs []uint64
|
||||
CollectionRewards []Reward
|
||||
Rewards [5][]Reward
|
||||
}
|
||||
|
||||
func LoadQuestDesign(root, version string, packID int) (map[int]QuestDesign, error) {
|
||||
if packID <= 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid quest pack %d", packID)
|
||||
}
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
return loadQuestDesignDB(db, packID)
|
||||
}
|
||||
|
||||
func loadQuestDesignDB(db *sql.DB, packID int) (map[int]QuestDesign, error) {
|
||||
if db == nil || packID <= 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid quest design configuration")
|
||||
}
|
||||
rows, err := db.Query(fmt.Sprintf("SELECT id,ProtoBuf FROM QuestTable%d ORDER BY id", packID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: query QuestTable%d: %w", packID, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
type questRow struct {
|
||||
id int
|
||||
proto []byte
|
||||
}
|
||||
var questRows []questRow
|
||||
for rows.Next() {
|
||||
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
|
||||
}
|
||||
entry := QuestDesign{ID: id}
|
||||
collectionIDs, err := packedInts(proto, 6)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: quest%d collections: %w", id, err)
|
||||
}
|
||||
for _, collectionID := range collectionIDs {
|
||||
var collection []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CollectionTable WHERE id=?", collectionID).Scan(&collection); err != nil {
|
||||
return nil, fmt.Errorf("gamedata: quest%d collection%d: %w", id, collectionID, err)
|
||||
}
|
||||
itemID, err := requiredScalar(collection, 3)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if itemID != collectionID {
|
||||
return nil, fmt.Errorf("gamedata: quest%d collection%d identity mismatch%d", id, collectionID, itemID)
|
||||
}
|
||||
entry.CollectionRewards = append(entry.CollectionRewards, Reward{Type: 17, ID: collectionID, Count: 1})
|
||||
}
|
||||
entry.GiveQuestItemIDs, err = packedInts(proto, 29)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: quest%d give items: %w", id, err)
|
||||
}
|
||||
for field, dst := range map[int]*int{63: &entry.Type, 37: &entry.PriorQuestID, 35: &entry.NextQuestID} {
|
||||
values, err := packedInts(proto, field)
|
||||
if err != nil || len(values) > 1 || (len(values) == 1 && values[0] > uint64(^uint32(0)>>1)) {
|
||||
return nil, fmt.Errorf("gamedata: invalid quest %d field%d", id, field)
|
||||
}
|
||||
if len(values) == 1 {
|
||||
*dst = int(values[0])
|
||||
}
|
||||
}
|
||||
for slot := range len(entry.Rewards) {
|
||||
types, err := packedInts(proto, 56+slot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: quest %d reward slot %d type: %w", id, slot, err)
|
||||
}
|
||||
ids, err := packedInts(proto, 51+slot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: quest %d reward slot %d id: %w", id, slot, err)
|
||||
}
|
||||
counts, err := packedInts(proto, 46+slot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: quest %d reward slot %d count: %w", id, slot, err)
|
||||
}
|
||||
if len(types) != len(ids) || len(ids) != len(counts) {
|
||||
return nil, fmt.Errorf("gamedata: quest %d reward slot %d has mismatched arrays", id, slot)
|
||||
}
|
||||
for i := range types {
|
||||
// Currency has id zero; non-stackable costume/equipment rewards
|
||||
// have count zero. Both are valid GameData representations.
|
||||
if types[i] == 0 {
|
||||
return nil, fmt.Errorf("gamedata: quest %d reward slot %d has zero type", id, slot)
|
||||
}
|
||||
entry.Rewards[slot] = append(entry.Rewards[slot], Reward{Type: types[i], ID: ids[i], Count: counts[i]})
|
||||
}
|
||||
}
|
||||
result[id] = entry
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
_ "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) {
|
||||
db, release, err := OpenDatabase(root, version, "common")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
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.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
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.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RecruitRule is MercenaryScoutTable. Type zero uses MercenaryScout; type
|
||||
// one belongs to the separate special recruitment protocol.
|
||||
type RecruitRule struct {
|
||||
ID, CostumeID, PackID, Type, TalkGroupID, AppearProb uint64
|
||||
Costs []PromotionCost
|
||||
}
|
||||
type RecruitDesign struct {
|
||||
Rules map[uint64]RecruitRule
|
||||
Characters map[uint64]CharacterDesign
|
||||
AppearCount, AutoResetMinute, ResetCount, ResetType, ResetLimit uint64
|
||||
}
|
||||
|
||||
func (d *RecruitDesign) Character(id uint64) (CharacterDesign, bool) {
|
||||
v, ok := d.Characters[id]
|
||||
return v, ok
|
||||
}
|
||||
func LoadRecruitDesign(root, version string) (RecruitDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return RecruitDesign{}, err
|
||||
}
|
||||
defer closeDB()
|
||||
return loadRecruitDesign(db)
|
||||
}
|
||||
func loadRecruitDesign(db *sql.DB) (RecruitDesign, error) {
|
||||
d := RecruitDesign{Rules: map[uint64]RecruitRule{}}
|
||||
if db == nil {
|
||||
return d, fmt.Errorf("gamedata: nil recruit database")
|
||||
}
|
||||
err := friendshipRows(db, "SELECT id,ProtoBuf FROM MercenaryScoutTable ORDER BY id", func(id uint64, p []byte) error {
|
||||
r := RecruitRule{}
|
||||
for i, dst := range []*uint64{&r.ID, &r.CostumeID, &r.PackID, &r.Type, &r.TalkGroupID, &r.AppearProb} {
|
||||
v, e := friendshipScalar(p, []int{3, 2, 4, 9, 8, 1}[i])
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
*dst = v
|
||||
}
|
||||
counts, e := packedInts(p, 5)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
ids, e := packedInts(p, 6)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
types, e := packedInts(p, 7)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if r.ID != id || id == 0 || id > math.MaxInt32 || r.CostumeID == 0 || r.CostumeID > math.MaxInt32 || r.PackID > math.MaxInt32 || r.AppearProb > math.MaxInt32 || r.TalkGroupID > math.MaxInt32 || r.Type > 1 || len(counts) == 0 || len(counts) != len(ids) || len(ids) != len(types) {
|
||||
return fmt.Errorf("invalid recruit rule %d", id)
|
||||
}
|
||||
for i, count := range counts {
|
||||
if count == 0 || count > math.MaxInt32 || types[i] != 8 || ids[i] == 0 || ids[i] > math.MaxInt32 {
|
||||
return fmt.Errorf("unsupported recruit cost %d", id)
|
||||
}
|
||||
r.Costs = append(r.Costs, PromotionCost{Type: types[i], ID: ids[i], Count: count})
|
||||
}
|
||||
d.Rules[id] = r
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return d, fmt.Errorf("gamedata: recruitment: %w", err)
|
||||
}
|
||||
d.Characters = map[uint64]CharacterDesign{}
|
||||
for _, r := range d.Rules {
|
||||
c, e := loadGachaCharacterDesign(db, r.CostumeID)
|
||||
if e != nil {
|
||||
return d, e
|
||||
}
|
||||
d.Characters[r.CostumeID] = c
|
||||
}
|
||||
var defaults []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM SpecialScoutInfoTable WHERE id=0").Scan(&defaults); err != nil {
|
||||
return d, err
|
||||
}
|
||||
if err := decodeRecruitSpecialDefaults(defaults, &d); err != nil {
|
||||
return d, err
|
||||
}
|
||||
if len(d.Rules) == 0 {
|
||||
return d, fmt.Errorf("gamedata: empty recruitment table")
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func decodeRecruitSpecialDefaults(defaults []byte, d *RecruitDesign) error {
|
||||
resetID, err := friendshipScalar(defaults, 5)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resetID != 0 {
|
||||
return fmt.Errorf("gamedata: special recruitment currency has item ID")
|
||||
}
|
||||
for i, dst := range []*uint64{&d.AppearCount, &d.AutoResetMinute, &d.ResetCount, &d.ResetType, &d.ResetLimit} {
|
||||
v, e := friendshipScalar(defaults, []int{1, 2, 4, 6, 7}[i])
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
*dst = v
|
||||
}
|
||||
if d.AppearCount == 0 || d.AppearCount > math.MaxInt32 || d.AutoResetMinute == 0 || d.AutoResetMinute > uint64(math.MaxInt64/int64(time.Minute)) || d.ResetType != 3 || d.ResetCount == 0 || d.ResetCount > math.MaxInt32 || d.ResetLimit == 0 || d.ResetLimit > math.MaxInt32 {
|
||||
return fmt.Errorf("gamedata: invalid special recruitment defaults")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
type RecruitNPC struct {
|
||||
ID, MapID, ScoutID uint64
|
||||
QuestEnableTypes, QuestRanges []uint64
|
||||
QuestTypes map[uint64]uint64
|
||||
}
|
||||
|
||||
// LoadRecruitNPC resolves the Scout interaction in the selected pack database;
|
||||
// FieldNpcTable.PackId is often zero and is not the database's pack identity.
|
||||
func LoadRecruitNPC(root, version string, packID int, npcID uint64) (RecruitNPC, error) {
|
||||
if packID <= 0 || npcID == 0 || npcID > math.MaxInt32 {
|
||||
return RecruitNPC{}, fmt.Errorf("gamedata: invalid recruit NPC")
|
||||
}
|
||||
db, release, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", packID))
|
||||
if err != nil {
|
||||
return RecruitNPC{}, err
|
||||
}
|
||||
defer release()
|
||||
npc, err := loadRecruitNPC(db, npcID)
|
||||
if err != nil {
|
||||
return RecruitNPC{}, err
|
||||
}
|
||||
if len(npc.QuestRanges) == 0 {
|
||||
return npc, nil
|
||||
}
|
||||
common, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return RecruitNPC{}, err
|
||||
}
|
||||
defer closeDB()
|
||||
if err = loadRecruitNPCQuestTypes(common, packID, &npc); err != nil {
|
||||
return RecruitNPC{}, err
|
||||
}
|
||||
return npc, nil
|
||||
}
|
||||
|
||||
func loadRecruitNPC(db *sql.DB, npcID uint64) (RecruitNPC, error) {
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM FieldNpcTable WHERE id=?", npcID).Scan(&raw); err != nil {
|
||||
return RecruitNPC{}, fmt.Errorf("gamedata: recruit NPC %d: %w", npcID, err)
|
||||
}
|
||||
npc := RecruitNPC{QuestTypes: map[uint64]uint64{}}
|
||||
for field, dst := range map[int]*uint64{8: &npc.ID, 14: &npc.MapID} {
|
||||
values, err := packedInts(raw, field)
|
||||
if err != nil || len(values) != 1 || values[0] == 0 || values[0] > math.MaxInt32 {
|
||||
return RecruitNPC{}, fmt.Errorf("gamedata: invalid recruit NPC %d field%d", npcID, field)
|
||||
}
|
||||
*dst = values[0]
|
||||
}
|
||||
interactions, err := packedInts(raw, 9)
|
||||
if err != nil {
|
||||
return RecruitNPC{}, err
|
||||
}
|
||||
values, err := packedInts(raw, 12)
|
||||
if err != nil {
|
||||
return RecruitNPC{}, err
|
||||
}
|
||||
if npc.ID != npcID || len(interactions) != len(values) {
|
||||
return RecruitNPC{}, fmt.Errorf("gamedata: invalid recruit NPC interactions %d", npcID)
|
||||
}
|
||||
for i, interaction := range interactions {
|
||||
if interaction == 4 {
|
||||
npc.ScoutID = values[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if npc.ScoutID == 0 || npc.ScoutID > math.MaxInt32 {
|
||||
return RecruitNPC{}, fmt.Errorf("gamedata: NPC %d has no Scout interaction", npcID)
|
||||
}
|
||||
npc.QuestEnableTypes, err = packedInts(raw, 18)
|
||||
if err != nil {
|
||||
return RecruitNPC{}, err
|
||||
}
|
||||
npc.QuestRanges, err = packedInts(raw, 19)
|
||||
if err != nil {
|
||||
return RecruitNPC{}, err
|
||||
}
|
||||
if len(npc.QuestEnableTypes) != len(npc.QuestRanges) {
|
||||
return RecruitNPC{}, fmt.Errorf("gamedata: invalid recruit NPC quest ranges %d", npcID)
|
||||
}
|
||||
for i, quest := range npc.QuestRanges {
|
||||
if npc.QuestEnableTypes[i] > 3 || quest > math.MaxInt32 {
|
||||
return RecruitNPC{}, fmt.Errorf("gamedata: invalid recruit NPC quest gate %d", npcID)
|
||||
}
|
||||
}
|
||||
return npc, nil
|
||||
}
|
||||
|
||||
func loadRecruitNPCQuestTypes(db *sql.DB, packID int, npc *RecruitNPC) error {
|
||||
rows, err := db.Query(fmt.Sprintf("SELECT id,ProtoBuf FROM QuestTable%d", packID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var quest uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&quest, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
types, err := packedInts(raw, 63)
|
||||
if err != nil || len(types) > 1 {
|
||||
return fmt.Errorf("gamedata: invalid NPC quest type %d", quest)
|
||||
}
|
||||
var kind uint64
|
||||
if len(types) == 1 {
|
||||
kind = types[0]
|
||||
}
|
||||
npc.QuestTypes[quest] = kind
|
||||
}
|
||||
return rows.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,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,257 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"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
|
||||
special 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()
|
||||
return loadRewardGraph(db)
|
||||
}
|
||||
|
||||
func loadRewardGraph(db *sql.DB) (*RewardGraph, error) {
|
||||
g := &RewardGraph{boxes: map[uint64]uint64{}, direct: map[uint64]bool{}, special: 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
|
||||
}
|
||||
// RBD_DIRECT owns the box itself; RBD_OPEN grants its selected contents.
|
||||
// Design also contains special mode 10 boxes; only the verified OPEN
|
||||
// mode is automatically expanded, other modes retain their entity.
|
||||
if drop != 0 && drop != 1 && drop != 10 {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: unsupported random box drop type %d", drop)
|
||||
}
|
||||
g.direct[id] = drop != 0
|
||||
g.special[id] = drop == 10
|
||||
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) {
|
||||
return g.resolve(rewards, true)
|
||||
}
|
||||
|
||||
func (g *RewardGraph) resolve(rewards []BattleReward, openRoots bool) ([]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, bool) error
|
||||
emit = func(r BattleReward, forceOpen bool) 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 {
|
||||
if forceOpen && g.special[r.ID] {
|
||||
return fmt.Errorf("gamedata: special random box %d cannot use generic opening", r.ID)
|
||||
}
|
||||
if _, ok := g.boxes[r.ID]; !ok {
|
||||
return fmt.Errorf("gamedata: unknown random box %d", r.ID)
|
||||
}
|
||||
if _, ok := g.direct[r.ID]; !ok {
|
||||
return fmt.Errorf("gamedata: missing random box drop type %d", r.ID)
|
||||
}
|
||||
}
|
||||
if r.Type != 9 || !forceOpen && g.direct[r.ID] {
|
||||
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, false); 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 range count {
|
||||
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], false); e != nil {
|
||||
return e
|
||||
}
|
||||
break
|
||||
}
|
||||
x -= w
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, r := range rewards {
|
||||
if e := emit(r, openRoots); 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 opens RBD_OPEN wrappers at every level, preserving RBD_DIRECT
|
||||
// boxes as inventory items. Resolve force-opens only explicitly requested roots.
|
||||
func (g *RewardGraph) ResolveGranted(rewards []BattleReward) ([]BattleReward, error) {
|
||||
return g.resolve(rewards, false)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type PrestigeSkinSelling struct {
|
||||
DesignID, GroupID, ProductID, MagicGroupID, MagicID uint64
|
||||
}
|
||||
|
||||
type PrestigeSkinCatalog struct {
|
||||
Skins map[uint64]uint64
|
||||
Selling []PrestigeSkinSelling
|
||||
}
|
||||
|
||||
// LoadPrestigeSkinCatalog loads the skin ownership mapping and the separate
|
||||
// selling-page bindings. PrestigeSkinTable has no "limited" flag; availability
|
||||
// is therefore determined from the selling binding and the live shop window.
|
||||
func LoadPrestigeSkinCatalog(root, version string) (*PrestigeSkinCatalog, 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 func() { _ = rows.Close() }()
|
||||
catalog := &PrestigeSkinCatalog{Skins: 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
|
||||
}
|
||||
if id == 0 || costume == 0 || catalog.Skins[id] != 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid prestige skin %d", id)
|
||||
}
|
||||
catalog.Skins[id] = costume
|
||||
}
|
||||
if e := rows.Err(); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
_ = rows.Close()
|
||||
rows, e = db.Query("SELECT ProtoBuf FROM SkinSellingTable ORDER BY groupId,id")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if e = rows.Scan(&raw); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
values := make([]uint64, 6)
|
||||
for i, field := range []int{1, 2, 3, 5, 6} {
|
||||
values[i], e = optionalScalar(raw, field)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
if values[0] == 0 || values[1] == 0 || values[2] == 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid prestige selling row")
|
||||
}
|
||||
catalog.Selling = append(catalog.Selling, PrestigeSkinSelling{DesignID: values[0], GroupID: values[1], ProductID: values[2], MagicGroupID: values[3], MagicID: values[4]})
|
||||
}
|
||||
if e = rows.Err(); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
sort.Slice(catalog.Selling, func(i, j int) bool { return catalog.Selling[i].DesignID < catalog.Selling[j].DesignID })
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
// Giftable returns stable design IDs which are not currently represented by a
|
||||
// cash purchase page. Event-task sources are intentionally not treated as a
|
||||
// purchase page; their rewards are not purchasable shop goods.
|
||||
func (c *PrestigeSkinCatalog) Giftable(cashAvailable func(CashProductKey) bool) []uint64 {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
current := map[uint64]bool{}
|
||||
for _, row := range c.Selling {
|
||||
if row.MagicGroupID != 0 && cashAvailable != nil && cashAvailable(CashProductKey{GroupID: row.MagicGroupID, ProductID: row.MagicID}) {
|
||||
current[row.DesignID] = true
|
||||
}
|
||||
}
|
||||
result := make([]uint64, 0, len(c.Skins))
|
||||
for design := range c.Skins {
|
||||
if !current[design] {
|
||||
result = append(result, design)
|
||||
}
|
||||
}
|
||||
slices.Sort(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func LoadPrestigeSkins(root, version string) (map[uint64]uint64, error) {
|
||||
catalog, err := LoadPrestigeSkinCatalog(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return catalog.Skins, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user