refactor(all): reorganize packages, establish player ownership and isolate accounts

This commit is contained in:
2026-10-07 22:42:16 +08:00
parent 8e8ddeddb6
commit 06a83ada28
850 changed files with 42335 additions and 79891 deletions
@@ -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
}
+112
View File
@@ -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}]
}
+105
View File
@@ -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(&current); 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