feat: split client tooling and add OAuth server login
This commit is contained in:
@@ -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,90 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAggregateStatsAddsFlatHealth(t *testing.T) {
|
||||
got := AggregateStats(BaseStats{Health: 505}, []StatContribution{{Stat: StatHealth, Flat: 7}})
|
||||
if got.Health != 512 {
|
||||
t.Fatalf("health=%v, want 512", got.Health)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealTutorialStatsDoNotMisclassifyAttackAsHealth(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("BD2_REAL_GAMEDATA not configured")
|
||||
}
|
||||
base, err := CharacterBaseStats(root, "20260910162539", 350, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if base.Health != 505 {
|
||||
t.Fatalf("base health=%v, want 505", base.Health)
|
||||
}
|
||||
option, err := EquipmentOptionContribution(root, "20260910162539", EquipmentOption{GroupID: 1010010, ID: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if option.Stat != StatAttack || option.Flat != 7 {
|
||||
t.Fatalf("option=%+v", option)
|
||||
}
|
||||
if got := AggregateStats(base, []StatContribution{option}); got.Health != 505 {
|
||||
t.Fatalf("attack equipment changed health: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadedCharacterStatDesignMatchesDirectLookup(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("BD2_REAL_GAMEDATA not configured")
|
||||
}
|
||||
design, err := LoadPictorialDesign(root, "20260910162539")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := design.CharStats.BaseStats(350, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
direct, err := CharacterBaseStats(root, "20260910162539", 350, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded != direct {
|
||||
t.Fatalf("loaded stats=%+v, direct database lookup=%+v", loaded, direct)
|
||||
}
|
||||
if _, err := design.CharStats.BaseStats(0, 20); err == nil {
|
||||
t.Fatal("zero character id unexpectedly resolved")
|
||||
}
|
||||
if _, err := design.CharStats.BaseStats(350, 0); err == nil {
|
||||
t.Fatal("zero character level unexpectedly resolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateStatsAppliesPercentAfterFlat(t *testing.T) {
|
||||
got := AggregateStats(BaseStats{Health: 100}, []StatContribution{{Stat: StatHealth, Flat: 7, Percent: 0.1}})
|
||||
if got.Health != 117 {
|
||||
t.Fatalf("health=%v, want 117", got.Health)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateStatsMatchesOfficialGrowthSnapshot(t *testing.T) {
|
||||
// Before tutorial equipment 10010 entered the pictorial book, the real
|
||||
// AllCharRefresh response advertised HEALTH_PERCENT=0.015. The subsequent
|
||||
// official CharGrowth response returned HP=512 for level-20 char 350.
|
||||
got := AggregateStats(BaseStats{Health: 505}, []StatContribution{{Stat: StatHealth, Percent: 0.015}})
|
||||
if got.Health != 512 {
|
||||
t.Fatalf("health=%v, want official snapshot 512", got.Health)
|
||||
}
|
||||
|
||||
// Acquiring the tutorial equipment raised that account-wide pictorial
|
||||
// bonus to 0.0175. It displays as +8 HP, independently of the equipment's
|
||||
// own option 3, which is ATTACK_VALUE +7.
|
||||
afterPictorial := AggregateStats(BaseStats{Health: 505}, []StatContribution{{Stat: StatHealth, Percent: 0.0175}})
|
||||
if afterPictorial.Health != 513 {
|
||||
t.Fatalf("post-pictorial health=%v, want 513", afterPictorial.Health)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"bd2server/internal/server/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) {
|
||||
plain, err := ReadDatabase(root, version, fmt.Sprintf("pack%d", packID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-battle-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "pack.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
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 {
|
||||
if types[i] == 0 || ids[i] == 0 || 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,68 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstalledPack21FirstMonsterRewards(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("BD2_REAL_GAMEDATA not configured")
|
||||
}
|
||||
rewards, err := BattleRewards(root, "20260910162539", 21, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rewards) != 1 || rewards[0] != (BattleReward{Type: 8, ID: 8, Count: 3}) {
|
||||
t.Fatalf("pack21 monster1 rewards = %+v, want slime type8/item8 x3", rewards)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstalledPack22DeckAbsentFromPack21Rewards(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("BD2_REAL_GAMEDATA not configured")
|
||||
}
|
||||
rewards, err := BattleDeckRewards(root, "20260910162539", 22, 9)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rewards) != 1 || rewards[0] != (BattleReward{Type: 8, ID: 14, Count: 1}) {
|
||||
t.Fatalf("pack22 deck9 rewards = %+v, want type8/item14 x1", rewards)
|
||||
}
|
||||
if _, err := BattleDeckRewards(root, "20260910162539", 21, 9); err == nil {
|
||||
t.Fatal("pack21 unexpectedly contains pack22-only deck9")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstalledTutorialGrowthReachesLevel20(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("BD2_REAL_GAMEDATA not configured")
|
||||
}
|
||||
level, exp, refunds, err := CharacterGrowth(root, "20260910162539", 350, 1, 0, []GrowthMaterial{{ID: 8, Count: 3}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if level != 20 || exp != 0 {
|
||||
t.Fatalf("char350 + resource8x3 = level %d exp %d, want level20 exp0", level, exp)
|
||||
}
|
||||
if len(refunds) != 2 || refunds[0] != (GrowthMaterial{ID: 8, Count: 1}) || refunds[1] != (GrowthMaterial{ID: 7, Count: 3}) {
|
||||
t.Fatalf("refunds=%v, want resource8x1 + resource7x3", refunds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstalledTutorialGrowthClientSelectionRefund(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("BD2_REAL_GAMEDATA not configured")
|
||||
}
|
||||
level, exp, refunds, err := CharacterGrowth(root, "20260910162539", 350, 1, 0, []GrowthMaterial{{ID: 8, Count: 2}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if level != 20 || exp != 0 || len(refunds) != 1 || refunds[0] != (GrowthMaterial{ID: 7, Count: 3}) {
|
||||
t.Fatalf("growth = level%d exp%d refunds%v, want official resource7x3", level, exp, refunds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-char-awake-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
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.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.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.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable 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
|
||||
}
|
||||
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 {
|
||||
rows.Close()
|
||||
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 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: awakening character %d growth: %w", id, err)
|
||||
}
|
||||
maximum, _ := packedInts(growthProto, 9)
|
||||
if len(maximum) != 1 || maximum[0] == 0 {
|
||||
rows.Close()
|
||||
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 err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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,91 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testCharAwakeDesign() *CharAwakeDesign {
|
||||
levels := func(stat uint64, values ...float64) []CharAwakeGrowth {
|
||||
result := make([]CharAwakeGrowth, len(values))
|
||||
for i, value := range values {
|
||||
result[i] = CharAwakeGrowth{ID: uint64(i + 1), Costs: []CharAwakeCost{{Type: 8, ID: 701 + uint64(i), Count: uint64(i + 1)}, {Type: 4, Count: 100}}, StatType: stat, StatValue: value}
|
||||
}
|
||||
return result
|
||||
}
|
||||
character := CharAwakeCharacter{UniqueCharID: 35, Active: true}
|
||||
character.ImprintGrowth[0] = levels(1, 5, 10)
|
||||
character.ImprintGrowth[1] = levels(2, .01, .02)
|
||||
character.ImprintGrowth[2] = levels(3, 2, 4)
|
||||
character.AwakeGrowth = []CharAwakeGrowth{
|
||||
{ID: 100, Costs: []CharAwakeCost{{Type: 8, ID: 705, Count: 20}, {Type: 4, Count: 500}}, StatType: 4, StatValue: .12},
|
||||
{ID: 101, StatType: 14, StatValue: .1},
|
||||
}
|
||||
return &CharAwakeDesign{
|
||||
Characters: map[uint64]CharAwakeCharacter{35: character},
|
||||
Stages: map[uint64]CharAwakeCharacterStage{354: {UniqueCharID: 35, Grade: 5, GrowthGrade: 5, MaximumLevel: 100}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharAwakeImprintUsesCrossLevelCostsAndCumulativeStats(t *testing.T) {
|
||||
design := testCharAwakeDesign()
|
||||
costs, levels, err := design.ImprintCosts(35, [3]uint64{0, 0, 0}, []CharImprintTarget{{Slot: 1, TargetLevel: 2}, {Slot: 2, TargetLevel: 1}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if levels != [3]uint64{2, 1, 0} || len(costs) != 3 {
|
||||
t.Fatalf("levels=%v costs=%+v", levels, costs)
|
||||
}
|
||||
var gold uint64
|
||||
for _, cost := range costs {
|
||||
if cost.Type == 4 {
|
||||
gold += cost.Count
|
||||
}
|
||||
}
|
||||
if gold != 300 {
|
||||
t.Fatalf("gold=%d want=300", gold)
|
||||
}
|
||||
stats, err := design.CharAwakeContributions(35, levels, false)
|
||||
if err != nil || len(stats) != 2 || stats[0].Flat != 10 || stats[1].Percent != .01 {
|
||||
t.Fatalf("cumulative target-level stats=%+v err=%v", stats, err)
|
||||
}
|
||||
if _, _, err := design.ImprintCosts(35, levels, []CharImprintTarget{{Slot: 1, TargetLevel: 1}}); err == nil {
|
||||
t.Fatal("imprint downgrade accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharAwakeActivationRequiresAllSlotsAndAddsEveryEffect(t *testing.T) {
|
||||
design := testCharAwakeDesign()
|
||||
if _, err := design.AwakeCosts(35, [3]uint64{2, 2, 1}, false); err == nil {
|
||||
t.Fatal("incomplete imprint slots accepted")
|
||||
}
|
||||
costs, err := design.AwakeCosts(35, [3]uint64{2, 2, 2}, false)
|
||||
if err != nil || len(costs) != 2 || costs[0].ID != 705 || costs[1].Type != 4 {
|
||||
t.Fatalf("awakening costs=%+v err=%v", costs, err)
|
||||
}
|
||||
stats, err := design.CharAwakeContributions(35, [3]uint64{2, 2, 2}, true)
|
||||
if err != nil || len(stats) != 5 || stats[3].Stat != StatAttack || stats[3].Percent != .12 || stats[4].Option != 14 {
|
||||
t.Fatalf("awakening stats=%+v err=%v", stats, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharAwakeAgainstInstalledVersion23413(t *testing.T) {
|
||||
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
design, err := LoadCharAwakeDesign(root, "20260910162539")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := design.Characters[35]
|
||||
if len(design.Characters) != 86 || len(entry.ImprintGrowth[0]) != 10 || len(entry.ImprintGrowth[1]) != 10 || len(entry.ImprintGrowth[2]) != 10 || len(entry.AwakeGrowth) != 2 {
|
||||
t.Fatalf("installed awakening design characters=%d char35=%+v", len(design.Characters), entry)
|
||||
}
|
||||
if entry.AwakeGrowth[0].Costs[0] != (CharAwakeCost{Type: 8, ID: 705, Count: 200}) || entry.AwakeGrowth[1].StatType != 14 || entry.AwakeGrowth[1].StatValue != .1 {
|
||||
t.Fatalf("installed char35 awakening=%+v", entry.AwakeGrowth)
|
||||
}
|
||||
if unique, err := design.ValidateGrowthCompleted(354, 100); err != nil || unique != 35 {
|
||||
t.Fatalf("installed final stage unique=%d err=%v", unique, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type CostumePotentialCost struct{ Type, ID, Count uint64 }
|
||||
|
||||
type CostumePotentialNode struct {
|
||||
ID uint64
|
||||
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
|
||||
}
|
||||
|
||||
func LoadCostumePotentialDesign(root, version string) (*CostumePotentialDesign, error) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-costume-potential-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
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{}}
|
||||
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...)}
|
||||
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.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.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)
|
||||
unique, _ := packedInts(proto, 20)
|
||||
if len(grade) == 1 && len(unique) == 1 {
|
||||
d.CharacterGrade[id] = grade[0]
|
||||
d.CharacterUnique[id] = unique[0]
|
||||
}
|
||||
}
|
||||
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,52 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCostumePotentialValidatePrerequisitesAndAggregate(t *testing.T) {
|
||||
d := &CostumePotentialDesign{
|
||||
Nodes: map[uint64]map[uint64]CostumePotentialNode{100: {
|
||||
1: {ID: 1, Costs: []CostumePotentialCost{{Type: 4, Count: 10}}},
|
||||
2: {ID: 2, ConditionGrade: 2, Prerequisites: []uint64{1}, Costs: []CostumePotentialCost{{Type: 8, ID: 7, Count: 3}}},
|
||||
}},
|
||||
CostumeUnique: map[uint64]uint64{100: 9}, CharacterGrade: map[uint64]uint64{200: 2}, CharacterUnique: map[uint64]uint64{200: 9},
|
||||
}
|
||||
costs, err := d.Validate(100, 200, 0, nil, []uint64{1, 2})
|
||||
if err != nil || len(costs) != 2 {
|
||||
t.Fatalf("valid chain costs=%+v err=%v", costs, err)
|
||||
}
|
||||
if _, err := d.Validate(100, 200, 0, nil, []uint64{2}); err == nil {
|
||||
t.Fatal("missing prerequisite accepted")
|
||||
}
|
||||
if _, err := d.Validate(100, 201, 0, nil, []uint64{1}); err == nil {
|
||||
t.Fatal("wrong character accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostumePotentialAgainstInstalledVersion23413(t *testing.T) {
|
||||
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
design, err := LoadCostumePotentialDesign(root, "20260910162539")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nodes := make([]uint64, 0, len(design.Nodes[65103]))
|
||||
for id := range design.Nodes[65103] {
|
||||
nodes = append(nodes, id)
|
||||
}
|
||||
costs, err := design.Validate(65103, 6514, 0, nil, nodes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(nodes) != 17 || len(costs) < 17 {
|
||||
t.Fatalf("installed costume 65103 nodes=%d costs=%d", len(nodes), len(costs))
|
||||
}
|
||||
questCosts, err := design.Validate(3501, 354, 0, nil, []uint64{1})
|
||||
if err != nil || len(questCosts) != 1 || questCosts[0].Type != 4 || questCosts[0].Count == 0 {
|
||||
t.Fatalf("installed quest reward costume 3501 first node costs=%+v err=%v", questCosts, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"bd2server/internal/server/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)
|
||||
}
|
||||
|
||||
// questDatabaseEntry is the member in the 2.35.10 GameData archive that
|
||||
// contains the shared QuestTable* SQLite database, including QuestTable21.
|
||||
// Quest data is not stored in an individual per-pack database.
|
||||
const questDatabaseEntry = "9F251C63BC72551C681EE75D328FA090D56E444B"
|
||||
|
||||
// ReadQuestDatabase extracts the shared quest database. The member name is
|
||||
// opaque in the client archive, so callers must not derive it from a map ID.
|
||||
func ReadQuestDatabase(root, version string) ([]byte, error) {
|
||||
return readEntry(root, version, questDatabaseEntry, "quest tables")
|
||||
}
|
||||
|
||||
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 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,90 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/dbcrypt"
|
||||
)
|
||||
|
||||
// These tests cover the production archive/decryption contract. Interactive
|
||||
// queries against the installed 178 MB GameData belong in tools/gamedata_db.py
|
||||
// so ordinary Go tests never double as an ad-hoc database console.
|
||||
func TestReadDatabase(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
release := filepath.Join(root, "123", "release")
|
||||
if err := os.MkdirAll(release, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain := make([]byte, dbcrypt.PageSize)
|
||||
copy(plain, dbcrypt.Header)
|
||||
copy(plain[100:], []byte("CREATE TABLE QuestTable21"))
|
||||
encrypted, err := dbcrypt.EncryptPages(plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name, err := DatabaseName("pack21")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeDatabaseTestArchive(t, filepath.Join(release, ArchiveName), name, encrypted)
|
||||
got, err := ReadDatabase(root, "123", "pack21")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, plain) {
|
||||
t.Fatal("decrypted database mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadQuestDatabase(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
release := filepath.Join(root, "123", "release")
|
||||
if err := os.MkdirAll(release, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain := make([]byte, dbcrypt.PageSize)
|
||||
copy(plain, dbcrypt.Header)
|
||||
copy(plain[100:], []byte("CREATE TABLE QuestTable21"))
|
||||
encrypted, err := dbcrypt.EncryptPages(plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeDatabaseTestArchive(t, filepath.Join(release, ArchiveName), questDatabaseEntry, encrypted)
|
||||
got, err := ReadQuestDatabase(root, "123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, plain) {
|
||||
t.Fatal("decrypted quest database mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func writeDatabaseTestArchive(t *testing.T, path, member string, content []byte) {
|
||||
t.Helper()
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive := zip.NewWriter(file)
|
||||
entry, err := archive.Create(member)
|
||||
if err != nil {
|
||||
file.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := entry.Write(content); err != nil {
|
||||
archive.Close()
|
||||
file.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := archive.Close(); err != nil {
|
||||
file.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
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
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment making recipe %d malformed", id)
|
||||
}
|
||||
var box []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM RandomBoxTable WHERE id=?", boxIDs[0]).Scan(&box); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment making recipe %d random box: %w", id, err)
|
||||
}
|
||||
groups, _ := packedInts(box, 9)
|
||||
if len(groups) != 1 || groups[0] == 0 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment making random box %d malformed", boxIDs[0])
|
||||
}
|
||||
results, equipmentOnly, err := classifyEquipmentRewardPool(db, groups[0])
|
||||
if err != nil || !equipmentOnly || len(results) == 0 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment making recipe %d reward group: %w", id, err)
|
||||
}
|
||||
recipe := EquipmentCraftRecipe{ID: id, ResultCount: resultCounts[0], TalentLevel: talentLevels[0], Results: results}
|
||||
for i := range counts {
|
||||
if counts[i] == 0 || materialIDs[i] == 0 || materialTypes[i] == 0 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment making recipe %d invalid material", id)
|
||||
}
|
||||
recipe.Costs = append(recipe.Costs, PromotionCost{Type: materialTypes[i], ID: materialIDs[i], Count: counts[i]})
|
||||
}
|
||||
if err := design.loadEquipmentPool(db, results); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: equipment making recipe %d: %w", id, err)
|
||||
}
|
||||
design.Recipes[id] = recipe
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
talents := make(map[uint64]equipmentMakingTalent)
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM TalentTable")
|
||||
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.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.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.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, _ := packedInts(raw, 6)
|
||||
if len(need) == 1 {
|
||||
design.TalentNeed[[2]uint64{group, level}] = need[0]
|
||||
}
|
||||
}
|
||||
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,47 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEquipmentCraftAgainstInstalledCurrentVersion(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test")
|
||||
}
|
||||
design, err := LoadEquipmentCraftDesign(root, "20260923193640")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recipe, ok := design.Recipe(1)
|
||||
if !ok || recipe.TalentLevel != 1 || recipe.ResultCount != 1 || len(recipe.Costs) != 2 ||
|
||||
recipe.Costs[0] != (PromotionCost{Type: 8, ID: 204, Count: 3}) ||
|
||||
recipe.Costs[1] != (PromotionCost{Type: 8, ID: 201, Count: 3}) {
|
||||
t.Fatalf("recipe=%+v found=%t", recipe, ok)
|
||||
}
|
||||
design.draw = func(limit uint64) (uint64, error) { return 0, nil }
|
||||
generated, err := design.Generate(1)
|
||||
if err != nil || generated.Design.ID != 10010 || len(generated.Main) == 0 {
|
||||
t.Fatalf("generated=%+v err=%v", generated, err)
|
||||
}
|
||||
gain, catalyst, maximum, err := design.Talent(140, 1, 1, 1, 0)
|
||||
if err != nil || gain == 0 || catalyst == 0 || maximum == 0 || gain > maximum {
|
||||
t.Fatalf("talent gain=%d catalyst=%d maximum=%d err=%v", gain, catalyst, maximum, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentUpgradeBreakAgainstInstalledCurrentVersion(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test")
|
||||
}
|
||||
design, err := LoadEquipmentUpgradeDesign(root, "20260923193640")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rewards, err := design.BreakRewards(10010, 0)
|
||||
if err != nil || len(rewards) == 0 {
|
||||
t.Fatalf("break rewards=%+v err=%v", rewards, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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
|
||||
equipment map[uint64]EquipmentDesign
|
||||
}
|
||||
type EquipmentGacha struct {
|
||||
ID uint64
|
||||
Count int
|
||||
PriceType, Price uint64
|
||||
TicketIDs []uint64
|
||||
Pool []WeightedEquipment
|
||||
TicketOnly bool
|
||||
}
|
||||
type EquipmentGachaGroup struct{ ID, FixedID, PointCount, OneTimeGachaID, TenTimeGachaID 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 }
|
||||
|
||||
func LoadEquipmentGacha(root, version string) (*EquipmentGachaCatalog, error) {
|
||||
return LoadEquipmentGachaGroups(root, version, []uint64{10002, 9, 133, 206})
|
||||
}
|
||||
|
||||
// LoadEquipmentGachaGroups loads only groups selected by a captured dynamic
|
||||
// schedule. Presence in GameData alone does not mean a banner is open.
|
||||
func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*EquipmentGachaCatalog, error) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-equipment-gacha-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
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)
|
||||
if len(one) != 1 || len(ten) != 1 || len(fixed) != 1 || fixed[0] != 1 || len(points) != 1 || points[0] != 1 {
|
||||
return nil, fmt.Errorf("gamedata: equipment group %d malformed", 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]}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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
|
||||
}
|
||||
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
|
||||
}
|
||||
pool, equipmentOnly, err := classifyEquipmentRewardPool(db, reward[0])
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: ticket gacha %d: %w", id, err)
|
||||
}
|
||||
if !equipmentOnly {
|
||||
continue
|
||||
}
|
||||
g := EquipmentGacha{ID: id, Count: int(count[0]), TicketIDs: append([]uint64(nil), tickets...), Pool: pool, TicketOnly: true}
|
||||
c.Gachas[id] = g
|
||||
for _, item := range pool {
|
||||
if err := c.loadEquipmentTree(db, item); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var fixed []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM GachaFixedTable WHERE id=1").Scan(&fixed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sr, _ := packedInts(fixed, 1)
|
||||
ur, _ := packedInts(fixed, 3)
|
||||
reset, _ := packedInts(fixed, 6)
|
||||
if len(sr) != 1 || sr[0] != 10 || len(ur) != 1 || ur[0] != 100 || len(reset) != 1 || reset[0] != 1 {
|
||||
return nil, fmt.Errorf("gamedata: malformed equipment fixed table")
|
||||
}
|
||||
c.fixed = EquipmentFixedDesign{ID: 1, SRCount: sr[0], URCount: ur[0], Reset: true}
|
||||
return c, 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)
|
||||
if len(count) != 1 || len(reward) != 1 || len(price) != 1 || len(kind) != 1 || kind[0] != 3 || price[0] != uint64(count[0])*200 {
|
||||
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)
|
||||
}
|
||||
var want []uint64
|
||||
switch len(pool) {
|
||||
case 8: // permanent draw 200/201: one 3% five-star UR branch.
|
||||
want = []uint64{300, 200, 250, 850, 1700, 400, 1600, 4700}
|
||||
case 9: // pickup groups: UP and non-UP five-star UR branches.
|
||||
want = []uint64{150, 150, 200, 250, 850, 1700, 400, 1600, 4700}
|
||||
default:
|
||||
return EquipmentGacha{}, fmt.Errorf("gamedata: equipment gacha %d has %d branches", id, len(pool))
|
||||
}
|
||||
for i := range pool {
|
||||
if pool[i].Weight != want[i] {
|
||||
return EquipmentGacha{}, fmt.Errorf("gamedata: equipment gacha %d branch %d rate=%d", id, i, pool[i].Weight)
|
||||
}
|
||||
}
|
||||
return EquipmentGacha{ID: id, Count: int(count[0]), 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.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) 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) != 8 && len(g.Pool) != 9) {
|
||||
return nil, state, errors.New("gamedata: invalid equipment gacha")
|
||||
}
|
||||
out := make([]uint64, g.Count)
|
||||
for i := range out {
|
||||
forceUR := state.URCount+1 >= fixed.URCount
|
||||
forceSR := !forceUR && state.SRCount+1 >= fixed.SRCount
|
||||
var id uint64
|
||||
var err error
|
||||
switch {
|
||||
case forceUR:
|
||||
id, err = rollEquipmentChoiceWith(equipmentGuaranteedPool(g.Pool, true), draw)
|
||||
state.URSort = i
|
||||
case forceSR:
|
||||
id, err = rollEquipmentChoiceWith(equipmentGuaranteedPool(g.Pool, false), draw)
|
||||
state.SRSort = i
|
||||
default:
|
||||
id, err = rollEquipmentChoiceWith(g.Pool, draw)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, state, err
|
||||
}
|
||||
out[i] = id
|
||||
tier := equipmentTier(g.Pool, id)
|
||||
switch tier {
|
||||
case 2:
|
||||
state.SRCount = 0
|
||||
state.URCount = 0
|
||||
case 1:
|
||||
state.SRCount = 0
|
||||
state.URCount++
|
||||
default:
|
||||
state.SRCount++
|
||||
state.URCount++
|
||||
}
|
||||
}
|
||||
return out, state, nil
|
||||
}
|
||||
|
||||
func equipmentGuaranteedPool(pool []WeightedEquipment, ur bool) []WeightedEquipment {
|
||||
if len(pool) == 8 {
|
||||
if ur {
|
||||
return []WeightedEquipment{{Weight: 15, Children: pool[0:1]}, {Weight: 35, Children: pool[2:3]}, {Weight: 50, Children: pool[5:6]}}
|
||||
}
|
||||
return []WeightedEquipment{{Weight: 15, Children: pool[1:2]}, {Weight: 35, Children: pool[3:4]}, {Weight: 50, Children: pool[6:7]}}
|
||||
}
|
||||
if ur {
|
||||
return []WeightedEquipment{{Weight: 15, Children: pool[0:2]}, {Weight: 35, Children: pool[3:4]}, {Weight: 50, Children: pool[6:7]}}
|
||||
}
|
||||
return []WeightedEquipment{{Weight: 15, Children: pool[2:3]}, {Weight: 35, Children: pool[4:5]}, {Weight: 50, Children: pool[7:8]}}
|
||||
}
|
||||
func equipmentTier(pool []WeightedEquipment, id uint64) int {
|
||||
for i, item := range pool {
|
||||
if containsEquipment(item, id) {
|
||||
if len(pool) == 8 {
|
||||
switch i {
|
||||
case 0, 2, 5:
|
||||
return 2
|
||||
case 1, 3, 6:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
switch i {
|
||||
case 0, 1, 3, 6:
|
||||
return 2
|
||||
case 2, 4, 7:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
func containsEquipment(item WeightedEquipment, id uint64) bool {
|
||||
if item.ID != 0 {
|
||||
return item.ID == id
|
||||
}
|
||||
for _, child := range item.Children {
|
||||
if containsEquipment(child, id) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
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,355 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-equipment-option-reroll-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
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,188 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestEquipmentOptionRerollDesignReadsCostsAndRollsUnlockedSlots(t *testing.T) {
|
||||
db := optionRerollTestDatabase(t)
|
||||
defer db.Close()
|
||||
|
||||
insertOptionRerollEquipment(t, db, 77, 9, []uint64{100}, []uint64{200, 200}, []uint64{300})
|
||||
insertOptionRerollCost(t, db, 9, []uint64{0, 5}, []uint64{1000, 5}, []uint64{0, 17}, []uint64{4, 8})
|
||||
insertOptionChoice(t, db, 100, 1, 100)
|
||||
insertOptionChoice(t, db, 200, 7, 25)
|
||||
insertOptionChoice(t, db, 200, 8, 75)
|
||||
insertOptionChoice(t, db, 300, 10, 100)
|
||||
|
||||
design, err := loadEquipmentOptionRerollDesign(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, ok := design.Lookup(77)
|
||||
if !ok || item.OptionRerollID != 9 || len(item.MainGroups) != 1 || len(item.SubGroups) != 2 || len(item.PrivateGroups) != 1 {
|
||||
t.Fatalf("equipment=%+v found=%v", item, ok)
|
||||
}
|
||||
costs, err := design.Cost(77, 2)
|
||||
if err != nil || len(costs) != 2 || costs[0] != (PromotionCost{Type: 4, Count: 1000}) || costs[1] != (PromotionCost{Type: 8, ID: 17, Count: 15}) {
|
||||
t.Fatalf("costs=%+v err=%v", costs, err)
|
||||
}
|
||||
|
||||
draws := []uint64{0, 24, 25}
|
||||
design.draw = func(limit uint64) (uint64, error) {
|
||||
value := draws[0]
|
||||
draws = draws[1:]
|
||||
if value >= limit {
|
||||
t.Fatalf("test draw %d outside limit %d", value, limit)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
roll, err := design.RollUnlocked(77, EquipmentOptionRerollLocks{
|
||||
Main: []bool{false}, Sub: []bool{false, true}, Private: []bool{false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if roll.Main[0] != (EquipmentOptionChoice{GroupID: 100, ID: 1}) ||
|
||||
roll.Sub[0] != (EquipmentOptionChoice{GroupID: 200, ID: 7}) || roll.Sub[1] != (EquipmentOptionChoice{}) ||
|
||||
roll.Private[0] != (EquipmentOptionChoice{GroupID: 300, ID: 10}) {
|
||||
t.Fatalf("roll=%+v", roll)
|
||||
}
|
||||
if len(draws) != 0 {
|
||||
t.Fatalf("unused draws=%v", draws)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentOptionRerollDesignRejectsMismatchedCostArrays(t *testing.T) {
|
||||
db := optionRerollTestDatabase(t)
|
||||
defer db.Close()
|
||||
insertOptionRerollEquipment(t, db, 77, 9, []uint64{100}, []uint64{200}, nil)
|
||||
// LockItemCount has one entry but the other parallel arrays have two.
|
||||
insertOptionRerollCost(t, db, 9, []uint64{0}, []uint64{1000, 5}, []uint64{0, 17}, []uint64{4, 8})
|
||||
insertOptionChoice(t, db, 100, 1, 100)
|
||||
insertOptionChoice(t, db, 200, 7, 100)
|
||||
if _, err := loadEquipmentOptionRerollDesign(db); err == nil {
|
||||
t.Fatal("mismatched option-reroll resource arrays accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentOptionRerollCostRejectsOverflowAndBadLocks(t *testing.T) {
|
||||
design := &EquipmentOptionRerollDesign{
|
||||
Equipment: map[uint64]EquipmentOptionRerollItem{1: {
|
||||
ID: 1, OptionRerollID: 2, MainGroups: []uint64{10}, SubGroups: []uint64{20},
|
||||
}},
|
||||
Costs: map[uint64]EquipmentOptionRerollCost{2: {Resources: []EquipmentOptionRerollResource{{
|
||||
Type: 8, ID: 17, BaseCount: math.MaxUint64, LockCount: 1,
|
||||
}}}},
|
||||
Groups: map[uint64]OptionGroup{
|
||||
10: {ID: 10, Choices: []WeightedOption{{ID: 1, Weight: 1}}},
|
||||
20: {ID: 20, Choices: []WeightedOption{{ID: 2, Weight: 1}}},
|
||||
},
|
||||
}
|
||||
if _, err := design.Cost(1, 1); err == nil {
|
||||
t.Fatal("overflowing lock cost accepted")
|
||||
}
|
||||
if _, err := design.RollUnlocked(1, EquipmentOptionRerollLocks{Main: []bool{false}}); err == nil {
|
||||
t.Fatal("short option lock arrays accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentOptionRerollAgainstInstalledCurrentVersion(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
root = os.Getenv("BD2_TEST_GAMEDATA_ROOT")
|
||||
}
|
||||
if root == "" {
|
||||
t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test")
|
||||
}
|
||||
design, err := LoadEquipmentOptionRerollDesign(root, "20260923193640")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, ok := design.Lookup(943035)
|
||||
if !ok || item.OptionRerollID != 9435 || item.PrivateUniqueCharID != 35 || len(item.MainGroups) != 2 || item.MainGroups[0] != 1943010 || item.MainGroups[1] != 2943010 ||
|
||||
len(item.SubGroups) != 3 || item.SubGroups[0] != 943000 || item.SubGroups[1] != 943000 || item.SubGroups[2] != 943000 ||
|
||||
len(item.PrivateGroups) != 1 || item.PrivateGroups[0] != 3943035 {
|
||||
t.Fatalf("943035 design=%+v found=%v", item, ok)
|
||||
}
|
||||
for locked, wantMaterial := range []uint64{60, 120, 180} {
|
||||
cost, err := design.Cost(943035, uint64(locked))
|
||||
if err != nil || len(cost) != 2 || cost[0] != (PromotionCost{Type: 4, Count: 120000}) || cost[1] != (PromotionCost{Type: 8, ID: 17, Count: wantMaterial}) {
|
||||
t.Fatalf("locked=%d cost=%+v err=%v", locked, cost, err)
|
||||
}
|
||||
}
|
||||
if choices := design.Groups[943000].Choices; len(choices) != 8 || choices[0].Weight != 100 {
|
||||
t.Fatalf("943000 choices=%+v", choices)
|
||||
}
|
||||
if choices := design.Groups[1943010].Choices; len(choices) != 2 || choices[0].ID != 3 || choices[1].ID != 4 {
|
||||
t.Fatalf("1943010 changeable main choices=%+v", choices)
|
||||
}
|
||||
if conversion := design.Conversion; conversion == nil || conversion.Ratio != 10 || conversion.SourceType != 8 || conversion.SourceID != 16 || conversion.TargetType != 8 || conversion.TargetID != 17 {
|
||||
t.Fatalf("reroll material conversion=%+v", conversion)
|
||||
}
|
||||
}
|
||||
|
||||
func optionRerollTestDatabase(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE EquipmentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB);
|
||||
CREATE TABLE EquipmentOptionRerollTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB);
|
||||
CREATE TABLE EquipmentRerollDefaultTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB);
|
||||
CREATE TABLE EquipmentOptionTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id));
|
||||
`); err != nil {
|
||||
db.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func insertOptionRerollEquipment(t *testing.T, db *sql.DB, id, rerollID uint64, main, sub, private []uint64) {
|
||||
t.Helper()
|
||||
proto := testVarintField(nil, 6, id)
|
||||
proto = testPackedFields(proto, 12, main)
|
||||
proto = testVarintField(proto, 15, rerollID)
|
||||
proto = testPackedFields(proto, 17, private)
|
||||
proto = testPackedFields(proto, 21, sub)
|
||||
if _, err := db.Exec("INSERT INTO EquipmentTable VALUES (?,?)", id, proto); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertOptionRerollCost(t *testing.T, db *sql.DB, id uint64, locks, counts, ids, types []uint64) {
|
||||
t.Helper()
|
||||
proto := testVarintField(nil, 1, id)
|
||||
proto = testPackedFields(proto, 2, locks)
|
||||
proto = testPackedFields(proto, 3, counts)
|
||||
proto = testPackedFields(proto, 4, ids)
|
||||
proto = testPackedFields(proto, 5, types)
|
||||
if _, err := db.Exec("INSERT INTO EquipmentOptionRerollTable VALUES (?,?)", id, proto); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertOptionChoice(t *testing.T, db *sql.DB, groupID, id, weight uint64) {
|
||||
t.Helper()
|
||||
proto := wire.AppendDouble(nil, 1, float64(id))
|
||||
proto = testVarintField(proto, 2, weight)
|
||||
proto = testVarintField(proto, 3, groupID)
|
||||
proto = testVarintField(proto, 5, id)
|
||||
if _, err := db.Exec("INSERT INTO EquipmentOptionTable VALUES (?,?,?)", groupID, id, proto); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func testPackedFields(proto []byte, number int, values []uint64) []byte {
|
||||
for _, value := range values {
|
||||
proto = testPackedField(proto, number, value)
|
||||
}
|
||||
return proto
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// LoadEquipmentSlots reads EquipmentTable.SlotType for every installed item.
|
||||
// SlotType is a proto3 enum, so an absent field is the valid first slot (0).
|
||||
func LoadEquipmentSlots(root, version string) (map[uint64]uint64, error) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-equipment-slots-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
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 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,27 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadEquipmentSlotsKeepsProtoDefaultSlotZero(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec("CREATE TABLE EquipmentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO EquipmentTable VALUES (?,?),(?,?)", 10010, []byte{}, 943619, testVarintField(nil, 20, 4)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slots, err := loadEquipmentSlots(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(slots) != 2 || slots[10010] != 0 || slots[943619] != 4 {
|
||||
t.Fatalf("slots=%v", slots)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-equipment-smelting-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
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,69 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEquipmentSmeltingAgainstInstalledVersion23413(t *testing.T) {
|
||||
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
design, err := LoadEquipmentSmeltingDesign(root, "20260910162539")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
equipment := design.Equipment[943035]
|
||||
if equipment.Grade != 4 || equipment.RankGroup != 904 || equipment.MaxLevel != 9 {
|
||||
t.Fatalf("UR equipment smelting design=%+v", equipment)
|
||||
}
|
||||
cost, err := design.Cost(943035)
|
||||
if err != nil || len(cost) != 2 || cost[0] != (PromotionCost{Type: 4, Count: 80}) || cost[1] != (PromotionCost{Type: 8, ID: 10, Count: 30}) {
|
||||
t.Fatalf("UR smelting cost=%+v err=%v", cost, err)
|
||||
}
|
||||
if design.MaxStreak != 5000 || design.Mileage != (EquipmentSmeltingMileage{UseType: 8, UseID: 10, UseCount: 1000, RewardType: 68, RewardCount: 1}) {
|
||||
t.Fatalf("smelting limit=%d mileage=%+v", design.MaxStreak, design.Mileage)
|
||||
}
|
||||
if got := design.Ranks[[2]uint64{904, 1}].Values; len(got) != 4 || got[0] != 1 || got[3] != 4 {
|
||||
t.Fatalf("UR slot1 score values=%v", got)
|
||||
}
|
||||
if got := design.Ranks[[2]uint64{904, 1}].GrowthPoint; len(got) != 4 || got[0] != 34 || got[3] != 135 {
|
||||
t.Fatalf("UR slot1 growth points=%v", got)
|
||||
}
|
||||
if score, err := design.Score(943035, []uint64{1, 2, 3}); err != nil || score != 1+4+9 {
|
||||
t.Fatalf("score=%d err=%v", score, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentSmeltingMaximumRanksComeFromDesign(t *testing.T) {
|
||||
design := &EquipmentSmeltingDesign{
|
||||
Equipment: map[uint64]EquipmentSmeltingItem{1: {RankGroup: 9}},
|
||||
Ranks: map[[2]uint64]EquipmentSmeltingRank{
|
||||
{9, 1}: {Values: []uint64{1, 2}},
|
||||
{9, 2}: {Values: []uint64{1, 2, 3}},
|
||||
{9, 3}: {Values: []uint64{1, 2, 3, 4}},
|
||||
},
|
||||
}
|
||||
got, err := design.MaximumRanks(1)
|
||||
if err != nil || len(got) != 3 || got[0] != 2 || got[1] != 3 || got[2] != 4 {
|
||||
t.Fatalf("maximum ranks=%v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentSmeltingRejectsInvalidRank(t *testing.T) {
|
||||
design := &EquipmentSmeltingDesign{
|
||||
Equipment: map[uint64]EquipmentSmeltingItem{1: {Grade: 4, RankGroup: 9, MaxLevel: 9}},
|
||||
Ranks: map[[2]uint64]EquipmentSmeltingRank{
|
||||
{9, 1}: {Values: []uint64{1, 2, 3, 4}},
|
||||
{9, 2}: {Values: []uint64{2, 4, 6, 8}},
|
||||
{9, 3}: {Values: []uint64{3, 6, 9, 12}},
|
||||
},
|
||||
}
|
||||
if _, err := design.Score(1, []uint64{1, 0, 1}); err == nil {
|
||||
t.Fatal("uninitialized smelting rank accepted")
|
||||
}
|
||||
if score, err := design.Score(1, []uint64{4, 4, 4}); err != nil || score != 24 {
|
||||
t.Fatalf("maximum score=%d err=%v", score, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEquipmentTicketOnlyGachaAgainstInstalledVersion23413(t *testing.T) {
|
||||
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
catalog, err := LoadEquipmentGacha(root, "20260910162539")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gacha, ok := catalog.Gacha(71200001)
|
||||
if !ok || !gacha.TicketOnly || gacha.Count != 10 || gacha.Price != 0 || gacha.PriceType != 0 || len(gacha.TicketIDs) != 1 || gacha.TicketIDs[0] != 1104 {
|
||||
t.Fatalf("ticket-only equipment gacha=%+v ok=%v", gacha, ok)
|
||||
}
|
||||
if len(gacha.Pool) != 3 || gacha.Pool[0].Weight != 150 || gacha.Pool[1].Weight != 350 || gacha.Pool[2].Weight != 500 {
|
||||
t.Fatalf("ticket-only equipment pool=%+v", gacha.Pool)
|
||||
}
|
||||
if len(gacha.Pool[0].Children) != 50 || len(gacha.Pool[1].Children) != 9 || len(gacha.Pool[2].Children) != 14 {
|
||||
t.Fatalf("ticket-only equipment branches=%d/%d/%d", len(gacha.Pool[0].Children), len(gacha.Pool[1].Children), len(gacha.Pool[2].Children))
|
||||
}
|
||||
// These branches separate the owning character's star grade, not the
|
||||
// equipment rarity. Every candidate is EquipmentTable.Grade=4 (UR).
|
||||
for _, branch := range gacha.Pool {
|
||||
for _, item := range branch.Children {
|
||||
design, found := catalog.equipment[item.ID]
|
||||
if !found || design.Grade != 4 {
|
||||
t.Fatalf("UR-guaranteed candidate %d design=%+v found=%v", item.ID, design, found)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, grouped := catalog.GroupForGacha(gacha.ID); grouped {
|
||||
t.Fatal("standalone ticket draw incorrectly attached to a schedule group")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
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) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-equipment-upgrade-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
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.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.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.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,81 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestEquipmentUpgradeDesignReadsCostsAndRatio(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec("CREATE TABLE EquipmentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE EquipmentGrowthTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id)); CREATE TABLE EquipmentRankTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
equipment := testVarintField(testVarintField(testVarintField(nil, 4, 954), 13, 9), 19, 904)
|
||||
growth := testVarintField(testVarintField(nil, 4, 954), 5, 670)
|
||||
growth = testPackedField(growth, 1, 2)
|
||||
growth = testPackedField(growth, 2, 201)
|
||||
growth = testPackedField(growth, 3, 8)
|
||||
growth = testPackedField(growth, 7, 960)
|
||||
growth = testPackedField(growth, 8, 0)
|
||||
growth = testPackedField(growth, 9, 4)
|
||||
growth = wire.AppendDouble(growth, 10, 0.7)
|
||||
if _, err := db.Exec("INSERT INTO EquipmentTable VALUES (?,?)", 943035, equipment); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO EquipmentGrowthTable VALUES (?,?,?)", 954, 0, growth); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ratio []byte
|
||||
for _, value := range []float32{1, 0, 0, 0} {
|
||||
var raw [4]byte
|
||||
binary.LittleEndian.PutUint32(raw[:], math.Float32bits(value))
|
||||
ratio = append(ratio, raw[:]...)
|
||||
}
|
||||
for slot := 1; slot <= 3; slot++ {
|
||||
if _, err := db.Exec("INSERT INTO EquipmentRankTable VALUES (?,?,?)", 904, slot, wire.AppendBytes(nil, 4, ratio)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
design, err := loadEquipmentUpgradeDesign(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
level, maximum, err := design.Level(943035, 0)
|
||||
if err != nil || maximum != 9 || level.GrowthPoint != 670 || len(level.Costs) != 1 || level.Costs[0] != (PromotionCost{Type: 4, Count: 960}) || math.Abs(level.SuccessRatio-0.7) > 1e-12 {
|
||||
t.Fatalf("level=%+v maximum=%d err=%v", level, maximum, err)
|
||||
}
|
||||
if rank, err := design.RollRank(943035, 1); err != nil || rank != 1 {
|
||||
t.Fatalf("rank=%d err=%v", rank, err)
|
||||
}
|
||||
if rewards, err := design.BreakRewards(943035, 0); err != nil || len(rewards) != 1 || rewards[0] != (BattleReward{Type: 8, ID: 201, Count: 2}) {
|
||||
t.Fatalf("break rewards=%+v err=%v", rewards, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentUpgradeAgainstInstalledVersion23413(t *testing.T) {
|
||||
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
design, err := LoadEquipmentUpgradeDesign(root, "20260910162539")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
level, maximum, err := design.Level(943035, 0)
|
||||
if err != nil || maximum != 9 || len(level.Costs) != 1 || level.Costs[0] != (PromotionCost{Type: 4, Count: 960}) || level.SuccessRatio != 1 {
|
||||
t.Fatalf("installed level=%+v maximum=%d err=%v", level, maximum, err)
|
||||
}
|
||||
ratios := design.RankRatio[[2]uint64{904, 1}]
|
||||
if design.RankGroup[943035] != 904 || len(ratios) != 4 || math.Abs(ratios[0]-0.45) > 1e-6 || math.Abs(ratios[3]-0.01) > 1e-6 {
|
||||
t.Fatalf("installed rank group=%d ratios=%v", design.RankGroup[943035], ratios)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSingleSidedCostumeFixedThresholdsDoNotTriggerZeroSide(t *testing.T) {
|
||||
pool := []WeightedCostume{
|
||||
{Weight: 150, ID: 5001}, {Weight: 150, ID: 5002},
|
||||
{Weight: 1400, ID: 4001}, {Weight: 8300, ID: 3001},
|
||||
}
|
||||
gacha := RegularGacha{ID: 1, Count: 1, PriceType: 3, Price: 200, Pool: pool}
|
||||
chooseThree := func(limit uint64) (uint64, error) {
|
||||
if limit == officialRateScale {
|
||||
return limit - 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
roll, state, err := gacha.rollWithCostumeFixed(0, 0, GachaFixedDesign{ID: 3, CostumeGrade5Count: 10}, nil, nil, chooseThree)
|
||||
if err != nil || roll[0] != 3001 || state.CostumeGrade4Sort != -1 || state.CostumeGrade5Sort != -1 {
|
||||
t.Fatalf("five-only fixed roll=%v state=%+v err=%v", roll, state, err)
|
||||
}
|
||||
roll, state, err = gacha.rollWithCostumeFixed(9, 500, GachaFixedDesign{ID: 5, CostumeGrade4Count: 10, ResetOnMatchingGrade: true}, nil, nil, chooseThree)
|
||||
if err != nil || roll[0] != 4001 || state.CostumeGrade4Sort != 0 || state.CostumeGrade5Sort != -1 {
|
||||
t.Fatalf("four-only fixed roll=%v state=%+v err=%v", roll, state, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeCostumeRewardGroupExecutesAllChildren(t *testing.T) {
|
||||
program := &CostumeRewardGroup{ID: 1, DropCount: 1, DropType: 1, Entries: []CostumeRewardEntry{
|
||||
{ItemType: 11, ItemID: 5001, Count: 1, Weight: 1},
|
||||
{ItemType: 9, ItemID: 2, Count: 1, Weight: 1, Group: &CostumeRewardGroup{ID: 2, DropCount: 9, Entries: []CostumeRewardEntry{
|
||||
{ItemType: 11, ItemID: 4001, Count: 1, Weight: 1400},
|
||||
{ItemType: 11, ItemID: 3001, Count: 1, Weight: 8300},
|
||||
}}},
|
||||
}}
|
||||
if count, err := costumeRewardCount(program); err != nil || count != 10 {
|
||||
t.Fatalf("count=%d err=%v", count, err)
|
||||
}
|
||||
gacha := RegularGacha{ID: 1, Count: 10, PriceType: 2, Price: 500, RewardGroup: program}
|
||||
roll, err := gacha.rollRewardGroup(func(limit uint64) (uint64, error) { return 0, nil })
|
||||
if err != nil || len(roll) != 10 || roll[0] != 5001 {
|
||||
t.Fatalf("roll=%v err=%v", roll, err)
|
||||
}
|
||||
for i := 1; i < len(roll); i++ {
|
||||
if roll[i] != 4001 {
|
||||
t.Fatalf("roll[%d]=%d", i, roll[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoonriseSpecialSelectionUsesThreeChoicesAndGameDataRemainder(t *testing.T) {
|
||||
choices := make([]CostumeRewardEntry, 12)
|
||||
selected := make([]uint64, 12)
|
||||
for i := range choices {
|
||||
selected[i] = uint64(5001 + i)
|
||||
choices[i] = CostumeRewardEntry{ItemType: 11, ItemID: selected[i], Count: 1, Weight: 1}
|
||||
}
|
||||
gacha := RegularGacha{ID: 9100037, Count: 10, PriceType: 19, PriceID: 450030, Price: 1, RewardGroup: &CostumeRewardGroup{
|
||||
ID: 9100037, DropCount: 1, DropType: 1, Entries: []CostumeRewardEntry{
|
||||
{ItemType: 9, ItemID: 9100038, Count: 1, Weight: 1, Group: &CostumeRewardGroup{ID: 9100038, DropCount: 3, Entries: choices}},
|
||||
{ItemType: 9, ItemID: 9100039, Count: 1, Weight: 1, Group: &CostumeRewardGroup{ID: 9100039, DropCount: 7, Entries: []CostumeRewardEntry{
|
||||
{ItemType: 11, ItemID: 4001, Count: 1, Weight: 1443},
|
||||
{ItemType: 11, ItemID: 3001, Count: 1, Weight: 8557},
|
||||
}}},
|
||||
},
|
||||
}}
|
||||
draws := []uint64{0, 1, 2, 0, 9999, 0, 9999, 0, 9999, 0}
|
||||
roll, err := gacha.rollSpecialSelection(selected, func(limit uint64) (uint64, error) {
|
||||
value := draws[0]
|
||||
draws = draws[1:]
|
||||
return value % limit, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(roll) != 10 || roll[0] != selected[0] || roll[1] != selected[1] || roll[2] != selected[2] {
|
||||
t.Fatalf("selection results=%v", roll)
|
||||
}
|
||||
for _, id := range roll[3:] {
|
||||
if id != 4001 && id != 3001 {
|
||||
t.Fatalf("remainder contains non-GameData costume %d: %v", id, roll)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAndQueryStepUpDesign(t *testing.T) {
|
||||
characters := map[uint64]CharacterDesign{5001: {ID: 500, HP: 100}}
|
||||
catalog, err := NewRegularGachaCatalog(map[uint64]RegularGacha{
|
||||
10: {ID: 10, Count: 1, PriceType: 2, Price: 1, Pool: []WeightedCostume{{ID: 5001, Weight: 1}}},
|
||||
}, characters)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design := GachaStepUpDesign{ID: 30, Steps: []GachaStepDesign{{Step: 1, GroupID: 20122, GachaID: 10, FixedID: 5, IsDisplayFixedItem: 1}}}
|
||||
if err := catalog.AddStepUpDesign(design); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
step, ok := catalog.StepForGacha(10)
|
||||
if !ok || step.GroupID != 20122 || step.Step != 1 {
|
||||
t.Fatalf("step=%+v ok=%v", step, ok)
|
||||
}
|
||||
group, ok := catalog.StepUp(30)
|
||||
if !ok || len(group.Steps) != 1 || len(catalog.StepUps()) != 1 {
|
||||
t.Fatalf("group=%+v ok=%v", group, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveGachaAgainstInstalledVersion23510(t *testing.T) {
|
||||
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
regular, equipment, err := LoadActiveGachaForSchedules(root, "20260923193640",
|
||||
[]uint64{166, 30010, 30011, 206, 205, 208, 153, 72, 71, 207, 1009}, []uint64{29, 30})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []uint64{101, 10100113, 11000113, 10100145, 11000145, 10100040, 11000040, 10100146, 11000146, 31, 9100037, 8100118, 8100119, 8100120, 8100121, 8100122, 8100123, 8100124, 8100125} {
|
||||
if _, ok := regular.Gacha(id); !ok {
|
||||
t.Errorf("costume gacha %d missing", id)
|
||||
}
|
||||
}
|
||||
paid, ok := regular.Gacha(9100037)
|
||||
if !ok || paid.PriceType != 19 || paid.PriceID != 450030 || paid.Price != 1 || paid.RewardGroup == nil {
|
||||
t.Fatalf("paid selection gacha=%+v ok=%v", paid, ok)
|
||||
}
|
||||
if got := regular.SpecialSelectionCount(9100037); got != 3 || len(regular.SpecialSelectionIDs(9100037)) != 129 {
|
||||
t.Fatalf("paid selection guaranteed=%d eligible=%d", got, len(regular.SpecialSelectionIDs(9100037)))
|
||||
}
|
||||
one, ok := regular.Gacha(10100113)
|
||||
if !ok || one.Count != 1 || one.FreeCountDay != 1 || one.DailyPayGachaCount != 1 || one.DailyPayGachaPriceCount != 90 || one.Price != 200 {
|
||||
t.Fatalf("daily single gacha=%+v ok=%v", one, ok)
|
||||
}
|
||||
moonriseGroup, ok := regular.Group(30011)
|
||||
if !ok || moonriseGroup.BuyLimitCount != 10 || moonriseGroup.CashProductGroupID != 1500001 || moonriseGroup.CashProductID != 9100037 || moonriseGroup.SelectCount != 12 || moonriseGroup.SelectionChoiceRate != 100 {
|
||||
t.Fatalf("moonrise group=%+v ok=%v", moonriseGroup, ok)
|
||||
}
|
||||
permanentSelection, ok := regular.Group(10001)
|
||||
if !ok || permanentSelection.TenTimeGachaID != 101 || permanentSelection.SelectCount != 12 || permanentSelection.GachaSubType != 1 {
|
||||
t.Fatalf("permanent selection group=%+v ok=%v", permanentSelection, ok)
|
||||
}
|
||||
newbie, ok := regular.Group(1009)
|
||||
if !ok || newbie.TenTimeGachaID != 31 || newbie.BuyLimitCount != 30 || newbie.SelectCount != 3 || newbie.PointCount != 0 || newbie.FixedID != 3 || !newbie.UseSelectionOnlyFixedApply {
|
||||
t.Fatalf("newbie group=%+v ok=%v", newbie, ok)
|
||||
}
|
||||
for _, id := range []uint64{8100121, 8100125} {
|
||||
gacha, ok := regular.Gacha(id)
|
||||
if !ok || gacha.FixedCostumeID == 0 || gacha.RewardGroup == nil || len(gacha.Pool) != 3 {
|
||||
t.Fatalf("fixed step gacha %d=%+v ok=%v", id, gacha, ok)
|
||||
}
|
||||
}
|
||||
for _, fixedID := range []uint64{1, 3, 5} {
|
||||
if _, ok := regular.Fixed(fixedID); !ok {
|
||||
t.Errorf("fixed %d missing", fixedID)
|
||||
}
|
||||
}
|
||||
for _, groupID := range []uint64{29, 30} {
|
||||
stepUp, ok := regular.StepUp(groupID)
|
||||
if !ok || len(stepUp.Steps) != 4 {
|
||||
t.Errorf("step-up %d=%+v ok=%v", groupID, stepUp, ok)
|
||||
}
|
||||
}
|
||||
for _, id := range []uint64{20100082, 21000082, 20100034, 21000034, 20100070, 21000070, 20100083, 21000083} {
|
||||
if _, ok := equipment.Gacha(id); !ok {
|
||||
t.Errorf("equipment gacha %d missing", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyRegularGachaLoaderAgainstInstalledVersion23413(t *testing.T) {
|
||||
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
catalog, err := LoadRegularCostumeGacha(root, "20260910162539")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []uint64{100, 101, 10100084, 11000084, 10100145, 11000145, 10100072, 11000072, 8100118, 8100119, 8100120, 8100121} {
|
||||
if _, ok := catalog.Gacha(id); !ok {
|
||||
t.Errorf("legacy gacha %d missing", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package gamedata
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCostumeGuaranteedFourAndFiveCountsAcrossOneAndTen(t *testing.T) {
|
||||
pool := []WeightedCostume{
|
||||
{Weight: 150, Children: []WeightedCostume{{ID: 5001, Weight: 1}}},
|
||||
{Weight: 150, Children: []WeightedCostume{{ID: 5002, Weight: 1}}},
|
||||
{Weight: 1400, Children: []WeightedCostume{{ID: 4001, Weight: 1}}},
|
||||
{Weight: 8300, Children: []WeightedCostume{{ID: 3001, Weight: 1}}},
|
||||
}
|
||||
fixed := GachaFixedDesign{ID: 1, CostumeGrade4Count: 10, CostumeGrade5Count: 100, ResetOnMatchingGrade: true}
|
||||
chooseThree := func(limit uint64) (uint64, error) {
|
||||
if limit == 10000 {
|
||||
return 9999, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
// The first of a ten-pull hits the shared four-star boundary after nine
|
||||
// unsuccessful singles, and the next nine slots continue the new count.
|
||||
gacha := RegularGacha{ID: 1, Count: 10, PriceType: 3, Price: 2000, Pool: pool}
|
||||
roll, state, err := gacha.rollWithCostumeFixed(9, 25, fixed, nil, nil, chooseThree)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(roll) != 10 || roll[0] != 4001 || state.CostumeGrade4Sort != 0 || state.CostumeGrade4Count != 9 || state.CostumeGrade5Count != 35 || state.CostumeGrade5Sort != -1 {
|
||||
t.Fatalf("four-star boundary roll=%v state=%+v", roll, state)
|
||||
}
|
||||
// When both boundaries coincide, the five-star guarantee has priority;
|
||||
// it resets both counters and is sampled from the genuine 50/50 5-star
|
||||
// branches, never from a synthetic hardcoded character.
|
||||
roll, state, err = gacha.rollWithCostumeFixed(9, 99, fixed, nil, nil, chooseThree)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if roll[0] != 5001 || state.CostumeGrade5Sort != 0 || state.CostumeGrade4Sort != -1 || state.CostumeGrade4Count != 9 || state.CostumeGrade5Count != 9 {
|
||||
t.Fatalf("five-star priority roll=%v state=%+v", roll, state)
|
||||
}
|
||||
// Natural five-stars reset both counters without falsely reporting a
|
||||
// guaranteed slot in GachaFixedDBInfo.ApplySortId.
|
||||
gacha.Count = 1
|
||||
chooseFive := func(limit uint64) (uint64, error) { return 0, nil }
|
||||
roll, state, err = gacha.rollWithCostumeFixed(8, 98, fixed, nil, nil, chooseFive)
|
||||
if err != nil || roll[0] != 5001 || state.CostumeGrade4Count != 0 || state.CostumeGrade5Count != 0 || state.CostumeGrade4Sort != -1 || state.CostumeGrade5Sort != -1 {
|
||||
t.Fatalf("natural five-star roll=%v state=%+v err=%v", roll, state, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwelvePickSelectionAppliesToNaturalAndPityFiveStar(t *testing.T) {
|
||||
pool := []WeightedCostume{
|
||||
{Weight: 300, Children: []WeightedCostume{{ID: 5001, Weight: 1}}},
|
||||
{Weight: 1400, Children: []WeightedCostume{{ID: 4001, Weight: 1}}},
|
||||
{Weight: 8300, Children: []WeightedCostume{{ID: 3001, Weight: 1}}},
|
||||
}
|
||||
gacha := RegularGacha{ID: 101, Count: 1, PriceType: 3, Price: 200, Pool: pool}
|
||||
fixed := GachaFixedDesign{ID: 8, CostumeGrade4Count: 10, CostumeGrade5Count: 100, ResetOnMatchingGrade: true}
|
||||
selected := []uint64{5012, 5013}
|
||||
chooseFirst := func(limit uint64) (uint64, error) { return 0, nil }
|
||||
roll, state, err := gacha.rollWithCostumeFixed(0, 0, fixed, selected, selected, chooseFirst)
|
||||
if err != nil || len(roll) != 1 || roll[0] != 5012 || len(state.SelectionSorts) != 1 || state.SelectionSorts[0] != 0 || state.CostumeGrade5Sort != -1 {
|
||||
t.Fatalf("ordinary selected five roll=%v state=%+v err=%v", roll, state, err)
|
||||
}
|
||||
roll, state, err = gacha.rollWithCostumeFixed(9, 99, fixed, selected, selected, chooseFirst)
|
||||
if err != nil || len(roll) != 1 || roll[0] != 5012 || state.CostumeGrade5Sort != 0 || len(state.SelectionSorts) != 1 || state.SelectionSorts[0] != 0 {
|
||||
t.Fatalf("guaranteed selected five roll=%v state=%+v err=%v", roll, state, err)
|
||||
}
|
||||
chooseThree := func(limit uint64) (uint64, error) {
|
||||
if limit == 10000 {
|
||||
return 9999, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
roll, state, err = gacha.rollWithCostumeFixed(0, 0, fixed, selected, selected, chooseThree)
|
||||
if err != nil || roll[0] != 3001 || len(state.SelectionSorts) != 0 || state.CostumeGrade4Count != 1 || state.CostumeGrade5Count != 1 {
|
||||
t.Fatalf("ordinary three roll=%v state=%+v err=%v", roll, state, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package gamedata
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestInfiniteGachaOfficialRateBoundariesAndFiveStarGuarantee(t *testing.T) {
|
||||
characters := map[uint64]CharacterDesign{
|
||||
5001: {ID: 500, HP: 100},
|
||||
4001: {ID: 400, HP: 100},
|
||||
3001: {ID: 300, HP: 100},
|
||||
}
|
||||
design, err := NewInfiniteGachaDesignWithRates(6, []uint64{5001}, []uint64{4001}, []uint64{3001}, characters)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The first five pairs are rate roll + pool index. They cover both
|
||||
// boundaries of 3%, the 14% interval and the start of the 83% interval.
|
||||
// The final value chooses the guaranteed five-star slot.
|
||||
draws := []uint64{0, 0, 299, 0, 300, 0, 1699, 0, 1700, 0, 0}
|
||||
position := 0
|
||||
roll, err := design.rollWith(func(limit uint64) (uint64, error) {
|
||||
if position >= len(draws) {
|
||||
t.Fatal("unexpected extra random draw")
|
||||
}
|
||||
value := draws[position]
|
||||
position++
|
||||
if value >= limit {
|
||||
t.Fatalf("draw %d exceeds limit %d", value, limit)
|
||||
}
|
||||
return value, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []uint64{5001, 5001, 4001, 4001, 3001, 5001}
|
||||
for i := range want {
|
||||
if roll[i] != want[i] {
|
||||
t.Fatalf("slot %d=%d want=%d; roll=%v", i, roll[i], want[i], roll)
|
||||
}
|
||||
}
|
||||
if position != len(draws) {
|
||||
t.Fatalf("used %d random draws, want %d", position, len(draws))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialPickupRateDefinition(t *testing.T) {
|
||||
pool := []WeightedCostume{
|
||||
{Weight: 150, ID: 1},
|
||||
{Weight: 150, ID: 2},
|
||||
{Weight: 1400, ID: 3},
|
||||
{Weight: 8300, ID: 4},
|
||||
}
|
||||
if err := validateOfficialPickupRates(pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool[0].Weight++
|
||||
if err := validateOfficialPickupRates(pool); err == nil {
|
||||
t.Fatal("accepted a pickup rate different from official GameData")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenPullGuaranteesGradeFourWhenAllNormalRollsAreGradeThree(t *testing.T) {
|
||||
gacha := RegularGacha{Count: 10, Pool: []WeightedCostume{
|
||||
{Weight: 150, ID: 5001}, {Weight: 150, ID: 5002}, {Weight: 1400, ID: 4001}, {Weight: 8300, ID: 3001},
|
||||
}}
|
||||
roll, err := gacha.rollWith(func(limit uint64) (uint64, error) {
|
||||
if limit == officialRateScale {
|
||||
return 9999, nil
|
||||
}
|
||||
return 0, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 9; i++ {
|
||||
if roll[i] != 3001 {
|
||||
t.Fatalf("slot %d=%d want grade-3", i, roll[i])
|
||||
}
|
||||
}
|
||||
if roll[9] != 4001 {
|
||||
t.Fatalf("guaranteed slot=%d want grade-4; roll=%v", roll[9], roll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedPickupTenPullAlwaysStartsWithPickupCostume(t *testing.T) {
|
||||
gacha := RegularGacha{Count: 10, FixedCostumeID: 21201, Pool: []WeightedCostume{
|
||||
{Weight: 300, ID: 5002}, {Weight: 1400, ID: 4001}, {Weight: 8300, ID: 3001},
|
||||
}}
|
||||
roll, err := gacha.rollWith(func(limit uint64) (uint64, error) { return limit - 1, nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(roll) != 10 || roll[0] != 21201 {
|
||||
t.Fatalf("roll=%v", roll)
|
||||
}
|
||||
for i := 1; i < 10; i++ {
|
||||
if roll[i] != 3001 {
|
||||
t.Fatalf("slot %d=%d want grade-3", i, roll[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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")
|
||||
}
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-growth-")
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
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 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.
|
||||
// The official 2.34.13 server uses larger denominations greedily and rounds
|
||||
// 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 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,105 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "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) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-inventory-slots-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
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 != 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,37 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestLoadInventorySlotDesignUsesGameDefaultFields(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", "file::memory:?cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec("CREATE TABLE GameDefaultTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raw []byte
|
||||
for field, value := range map[int]uint64{
|
||||
9: 300, 10: 4, 11: 300, 12: 4, 13: 300, 14: 4, 15: 300, 16: 4,
|
||||
30: 500, 31: 100, 34: 100, 37: 100, 73: 2000, 74: 400, 79: 10000, 80: 500, 83: 400,
|
||||
} {
|
||||
raw = wire.AppendVarint(raw, field, value)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO GameDefaultTable(id,ProtoBuf) VALUES(0,?)", raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design, err := loadInventorySlotDesign(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if design.Items.Default != 100 || design.Items.Maximum != 500 || design.Equipment.Default != 500 || design.Equipment.Maximum != 2000 || design.Storage.Maximum != 400 || design.EquipmentStorage.Maximum != 400 {
|
||||
t.Fatalf("design=%+v", design)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "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
|
||||
Params []uint64
|
||||
TargetValue uint64
|
||||
UnlockPack uint64
|
||||
UnlockQuest uint64
|
||||
}
|
||||
|
||||
type SectionRewardDesign struct {
|
||||
SectionValue uint64
|
||||
Rewards []Reward
|
||||
}
|
||||
|
||||
type AchievementDesign struct {
|
||||
AddExp uint64
|
||||
Rewards []Reward
|
||||
}
|
||||
|
||||
// LoadMissionDesign loads the three non-event design tables from the shared
|
||||
// 2.34.13 database. Event missions intentionally remain outside this reader:
|
||||
// their eligibility depends on an active server schedule, not just GameData.
|
||||
func LoadMissionDesign(root, version string) (*MissionDesign, error) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-mission-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
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 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)
|
||||
}
|
||||
design.Conditions[key] = MissionCondition{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 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 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 contents == 0 || 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)
|
||||
}
|
||||
key := AchievementKey{contents, groupID, id}
|
||||
if _, exists := design.Achievements[key]; exists {
|
||||
return fmt.Errorf("gamedata: duplicate AchievementTable key %+v", key)
|
||||
}
|
||||
design.Achievements[key] = AchievementDesign{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,120 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// LoadQuestPackChain opens the encrypted quest database once and follows the
|
||||
// authoritative PackTable.NextPackId chain. Every pack receives its own quest
|
||||
// map because quest IDs are only unique inside a pack.
|
||||
func LoadQuestPackChain(root, version string, startPackID int) (map[int]map[int]QuestDesign, map[int]PackTransition, error) {
|
||||
if startPackID <= 0 {
|
||||
return nil, nil, fmt.Errorf("gamedata: invalid story start pack %d", startPackID)
|
||||
}
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-pack-chain-")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
packs := make(map[int]map[int]QuestDesign)
|
||||
transitions := make(map[int]PackTransition)
|
||||
visited := make(map[int]bool)
|
||||
for packID := startPackID; packID != 0; {
|
||||
if visited[packID] {
|
||||
return nil, nil, fmt.Errorf("gamedata: cyclic next-pack edge at %d", packID)
|
||||
}
|
||||
if len(visited) >= 64 {
|
||||
return nil, nil, fmt.Errorf("gamedata: story pack chain exceeds 64 packs")
|
||||
}
|
||||
visited[packID] = true
|
||||
quests, err := loadQuestDesignDB(db, packID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(quests) == 0 {
|
||||
return nil, nil, fmt.Errorf("gamedata: QuestTable%d is empty", packID)
|
||||
}
|
||||
packs[packID] = quests
|
||||
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM PackTable WHERE id=?", packID).Scan(&raw); err != nil {
|
||||
return nil, nil, fmt.Errorf("gamedata: pack %d: %w", packID, err)
|
||||
}
|
||||
next, err := packedInts(raw, 45)
|
||||
if err != nil || len(next) > 1 {
|
||||
return nil, nil, fmt.Errorf("gamedata: pack %d has malformed next-pack edge", packID)
|
||||
}
|
||||
nextPackID := 0
|
||||
if len(next) == 1 {
|
||||
nextPackID = int(next[0])
|
||||
}
|
||||
transitions[packID] = PackTransition{PackID: packID, NextPackID: nextPackID}
|
||||
packID = nextPackID
|
||||
}
|
||||
return packs, transitions, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return PackTransition{}, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-pack-transition-")
|
||||
if err != nil {
|
||||
return PackTransition{}, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return PackTransition{}, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return PackTransition{}, err
|
||||
}
|
||||
defer db.Close()
|
||||
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 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,353 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-promotion-")
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
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) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-promotion-growth-")
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
defer db.Close()
|
||||
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))
|
||||
for key, count := range requested {
|
||||
remaining[key] = count
|
||||
}
|
||||
for _, cost := range cumulative {
|
||||
key := [2]uint64{cost.Type, cost.ID}
|
||||
if remaining[key] < cost.Count {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: missing cumulative promotion cost %d/%d x%d", cost.Type, cost.ID, cost.Count-remaining[key])
|
||||
}
|
||||
remaining[key] -= cost.Count
|
||||
}
|
||||
if remaining[[2]uint64{4, 0}] != 0 {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: unexpected promotion gold remainder")
|
||||
}
|
||||
|
||||
var gained uint64
|
||||
for key, count := range remaining {
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
if key[0] != 8 || key[1] == 0 {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: unexpected combined promotion material %d/%d x%d", key[0], key[1], count)
|
||||
}
|
||||
var proto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM ResourceTable WHERE id=?", key[1]).Scan(&proto); err != nil {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: promotion growth resource %d: %w", key[1], err)
|
||||
}
|
||||
values, err := packedInts(proto, 9)
|
||||
if err != nil || len(values) != 1 || values[0] == 0 {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: resource %d is not an experience material", key[1])
|
||||
}
|
||||
if count > (^uint64(0)-gained)/values[0] {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: combined promotion experience overflow")
|
||||
}
|
||||
gained += count * values[0]
|
||||
}
|
||||
|
||||
currentID = uint64(charID)
|
||||
currentLevel := level
|
||||
currentExp := exp
|
||||
for _, stage := range stages {
|
||||
if currentID != stage.fromID {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: invalid promotion stage sequence")
|
||||
}
|
||||
definition, err := growthDefinition(db, currentID)
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
currentLevel, currentExp, gained, err = applyGrowthExperience(db, definition, currentLevel, currentExp, gained)
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
if currentLevel != stage.maximum {
|
||||
return PromotionGrowthResult{}, fmt.Errorf("gamedata: insufficient experience to reach promotion cap %d", definition.maximum)
|
||||
}
|
||||
currentID = stage.nextID
|
||||
}
|
||||
finalDefinition, err := growthDefinition(db, currentID)
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
currentLevel, currentExp, gained, err = applyGrowthExperience(db, finalDefinition, currentLevel, currentExp, gained)
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
refunds, err := refundGrowthResources(db, gained)
|
||||
if err != nil {
|
||||
return PromotionGrowthResult{}, err
|
||||
}
|
||||
return PromotionGrowthResult{CharacterID: currentID, Level: currentLevel, Exp: currentExp, Costs: cumulative, Refunds: refunds}, nil
|
||||
}
|
||||
|
||||
type characterGrowthDefinition struct {
|
||||
group, maximum uint64
|
||||
}
|
||||
|
||||
func growthDefinition(db *sql.DB, charID uint64) (characterGrowthDefinition, error) {
|
||||
var charProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(&charProto); err != nil {
|
||||
return characterGrowthDefinition{}, fmt.Errorf("gamedata: character %d: %w", charID, err)
|
||||
}
|
||||
growthIDs, _ := packedInts(charProto, 1)
|
||||
if len(growthIDs) != 1 {
|
||||
return characterGrowthDefinition{}, fmt.Errorf("gamedata: character %d has no growth definition", charID)
|
||||
}
|
||||
var growthProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthIDs[0]).Scan(&growthProto); err != nil {
|
||||
return characterGrowthDefinition{}, err
|
||||
}
|
||||
groups, _ := packedInts(growthProto, 1)
|
||||
maximum, _ := packedInts(growthProto, 9)
|
||||
if len(groups) != 1 || len(maximum) != 1 {
|
||||
return characterGrowthDefinition{}, fmt.Errorf("gamedata: invalid growth definition %d", growthIDs[0])
|
||||
}
|
||||
return characterGrowthDefinition{group: groups[0], maximum: maximum[0]}, nil
|
||||
}
|
||||
|
||||
func promotionDefinition(db *sql.DB, charID uint64) (uint64, []PromotionCost, uint64, error) {
|
||||
definition, err := growthDefinition(db, charID)
|
||||
if err != nil {
|
||||
return 0, nil, 0, err
|
||||
}
|
||||
var charProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(&charProto); err != nil {
|
||||
return 0, nil, 0, err
|
||||
}
|
||||
next, _ := packedInts(charProto, 15)
|
||||
if len(next) != 1 || next[0] == 0 {
|
||||
return 0, nil, 0, fmt.Errorf("gamedata: character %d has no next stage", charID)
|
||||
}
|
||||
growthIDs, _ := packedInts(charProto, 1)
|
||||
var growthProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthIDs[0]).Scan(&growthProto); err != nil {
|
||||
return 0, nil, 0, err
|
||||
}
|
||||
types, _ := packedInts(growthProto, 4)
|
||||
ids, _ := packedInts(growthProto, 3)
|
||||
counts, _ := packedInts(growthProto, 2)
|
||||
if len(types) == 0 || len(types) != len(ids) || len(types) != len(counts) {
|
||||
return 0, nil, 0, fmt.Errorf("gamedata: invalid promotion costs for character %d", charID)
|
||||
}
|
||||
costs := make([]PromotionCost, len(types))
|
||||
for i := range costs {
|
||||
costs[i] = PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]}
|
||||
}
|
||||
return next[0], costs, definition.maximum, nil
|
||||
}
|
||||
|
||||
func applyGrowthExperience(db *sql.DB, definition characterGrowthDefinition, level, exp, gained uint64) (uint64, uint64, uint64, error) {
|
||||
if level > definition.maximum {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: level %d exceeds growth maximum %d", level, definition.maximum)
|
||||
}
|
||||
for gained > 0 && level < definition.maximum {
|
||||
var proto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharLevelTable WHERE GroupId=? AND id=?", definition.group, level).Scan(&proto); err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
required, _ := packedInts(proto, 8)
|
||||
if len(required) != 1 || required[0] == 0 || exp >= required[0] {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: invalid level curve group %d level %d", definition.group, level)
|
||||
}
|
||||
need := required[0] - exp
|
||||
if gained < need {
|
||||
exp += gained
|
||||
gained = 0
|
||||
break
|
||||
}
|
||||
gained -= need
|
||||
level++
|
||||
exp = 0
|
||||
}
|
||||
if level == definition.maximum {
|
||||
exp = 0
|
||||
}
|
||||
return level, exp, gained, nil
|
||||
}
|
||||
|
||||
func characterPromotion(db *sql.DB, charID int, level, exp uint64) (uint64, []PromotionCost, error) {
|
||||
if charID <= 0 || level == 0 {
|
||||
return 0, nil, fmt.Errorf("gamedata: invalid promotion character or level")
|
||||
}
|
||||
var current []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(¤t); err != nil {
|
||||
return 0, nil, fmt.Errorf("gamedata: promotion character %d: %w", charID, err)
|
||||
}
|
||||
next, err := packedInts(current, 15)
|
||||
if err != nil || len(next) != 1 || next[0] == 0 {
|
||||
return 0, nil, fmt.Errorf("gamedata: character %d has no next stage", charID)
|
||||
}
|
||||
growthID, err := packedInts(current, 1)
|
||||
if err != nil || len(growthID) != 1 {
|
||||
return 0, nil, fmt.Errorf("gamedata: character %d has no growth definition", charID)
|
||||
}
|
||||
var growth []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthID[0]).Scan(&growth); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
maximum, _ := packedInts(growth, 9)
|
||||
group, _ := packedInts(growth, 1)
|
||||
if len(maximum) != 1 || len(group) != 1 || level != maximum[0] {
|
||||
return 0, nil, fmt.Errorf("gamedata: character %d has not reached its promotion level", charID)
|
||||
}
|
||||
var curve []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharLevelTable WHERE GroupId=? AND id=?", group[0], level).Scan(&curve); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
required, _ := packedInts(curve, 8)
|
||||
if len(required) != 0 && exp < required[0] {
|
||||
return 0, nil, fmt.Errorf("gamedata: character %d has not reached its promotion experience", charID)
|
||||
}
|
||||
var nextProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", next[0]).Scan(&nextProto); err != nil {
|
||||
return 0, nil, fmt.Errorf("gamedata: next character %d: %w", next[0], err)
|
||||
}
|
||||
nextGrowth, _ := packedInts(nextProto, 1)
|
||||
if len(nextGrowth) != 1 || nextGrowth[0] == growthID[0] {
|
||||
return 0, nil, fmt.Errorf("gamedata: invalid promotion %d -> %d", charID, next[0])
|
||||
}
|
||||
types, _ := packedInts(growth, 4)
|
||||
ids, _ := packedInts(growth, 3)
|
||||
counts, _ := packedInts(growth, 2)
|
||||
if len(types) == 0 || len(types) != len(ids) || len(types) != len(counts) {
|
||||
return 0, nil, fmt.Errorf("gamedata: invalid promotion costs for character %d", charID)
|
||||
}
|
||||
costs := make([]PromotionCost, len(types))
|
||||
for i := range costs {
|
||||
if counts[i] == 0 || (types[i] != 4 && ids[i] == 0) {
|
||||
return 0, nil, fmt.Errorf("gamedata: invalid promotion cost for character %d", charID)
|
||||
}
|
||||
costs[i] = PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]}
|
||||
}
|
||||
return next[0], costs, nil
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCharacterGrowthPromotionsAgainstInstalledVersion23413(t *testing.T) {
|
||||
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
submitted := []PromotionCost{
|
||||
{Type: 8, ID: 9, Count: 753},
|
||||
{Type: 8, ID: 11, Count: 1},
|
||||
{Type: 8, ID: 12, Count: 2},
|
||||
{Type: 8, ID: 13, Count: 3},
|
||||
{Type: 8, ID: 14, Count: 4},
|
||||
{Type: 4, Count: 10000},
|
||||
}
|
||||
result, err := CharacterGrowthPromotions(root, "20260910162539", 6510, 1, 0, submitted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.CharacterID != 6514 || result.Level != 100 || result.Exp != 0 || len(result.Costs) != 8 {
|
||||
t.Fatalf("installed 2.34.13 combined promotion=%+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func testVarintField(dst []byte, field int, value uint64) []byte {
|
||||
dst = binary.AppendUvarint(dst, uint64(field<<3))
|
||||
return binary.AppendUvarint(dst, value)
|
||||
}
|
||||
|
||||
func testPackedField(dst []byte, field int, values ...uint64) []byte {
|
||||
var packed []byte
|
||||
for _, value := range values {
|
||||
packed = binary.AppendUvarint(packed, value)
|
||||
}
|
||||
dst = binary.AppendUvarint(dst, uint64(field<<3|2))
|
||||
dst = binary.AppendUvarint(dst, uint64(len(packed)))
|
||||
return append(dst, packed...)
|
||||
}
|
||||
|
||||
func TestCharacterPromotionUsesCharAndGrowthTables(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
for _, schema := range []string{
|
||||
"CREATE TABLE CharTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)",
|
||||
"CREATE TABLE CharGrowthTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)",
|
||||
"CREATE TABLE CharLevelTable (GroupId INTEGER, id INTEGER, ProtoBuf BLOB, PRIMARY KEY(GroupId,id))",
|
||||
} {
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
current := testVarintField(nil, 1, 101)
|
||||
current = testVarintField(current, 15, 351)
|
||||
next := testVarintField(nil, 1, 102)
|
||||
growth := testVarintField(nil, 1, 101)
|
||||
growth = testPackedField(growth, 2, 1, 1000)
|
||||
growth = testPackedField(growth, 3, 11, 0)
|
||||
growth = testPackedField(growth, 4, 8, 4)
|
||||
growth = testVarintField(growth, 9, 20)
|
||||
if _, err := db.Exec("INSERT INTO CharTable VALUES (?,?),(?,?)", 350, current, 351, next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO CharGrowthTable VALUES (?,?)", 101, growth); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO CharLevelTable VALUES (?,?,?)", 101, 20, []byte{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nextID, costs, err := characterPromotion(db, 350, 20, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nextID != 351 || len(costs) != 2 || costs[0] != (PromotionCost{Type: 8, ID: 11, Count: 1}) || costs[1] != (PromotionCost{Type: 4, Count: 1000}) {
|
||||
t.Fatalf("promotion next=%d costs=%+v", nextID, costs)
|
||||
}
|
||||
if _, _, err := characterPromotion(db, 350, 19, 0); err == nil {
|
||||
t.Fatal("promotion before the stage cap was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharacterGrowthPromotionsCrossesTwoStagesWithCumulativeCosts(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
for _, schema := range []string{
|
||||
"CREATE TABLE CharTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)",
|
||||
"CREATE TABLE CharGrowthTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)",
|
||||
"CREATE TABLE CharLevelTable (GroupId INTEGER, id INTEGER, ProtoBuf BLOB, PRIMARY KEY(GroupId,id))",
|
||||
"CREATE TABLE ResourceTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)",
|
||||
} {
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, row := range []struct {
|
||||
id, growth, next uint64
|
||||
}{{352, 103, 353}, {353, 104, 354}, {354, 105, 0}} {
|
||||
proto := testVarintField(nil, 1, row.growth)
|
||||
if row.next != 0 {
|
||||
proto = testVarintField(proto, 15, row.next)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO CharTable VALUES (?,?)", row.id, proto); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
growth103 := testVarintField(nil, 1, 103)
|
||||
growth103 = testPackedField(growth103, 2, 3, 3000)
|
||||
growth103 = testPackedField(growth103, 3, 13, 0)
|
||||
growth103 = testPackedField(growth103, 4, 8, 4)
|
||||
growth103 = testVarintField(growth103, 9, 60)
|
||||
growth104 := testVarintField(nil, 1, 104)
|
||||
growth104 = testPackedField(growth104, 2, 4, 4000)
|
||||
growth104 = testPackedField(growth104, 3, 14, 0)
|
||||
growth104 = testPackedField(growth104, 4, 8, 4)
|
||||
growth104 = testVarintField(growth104, 9, 80)
|
||||
growth105 := testVarintField(testVarintField(nil, 1, 105), 9, 100)
|
||||
for _, row := range []struct {
|
||||
id uint64
|
||||
proto []byte
|
||||
}{{103, growth103}, {104, growth104}, {105, growth105}} {
|
||||
if _, err := db.Exec("INSERT INTO CharGrowthTable VALUES (?,?)", row.id, row.proto); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, curve := range []struct{ group, first, maximum uint64 }{{103, 1, 60}, {104, 60, 80}, {105, 80, 100}} {
|
||||
for level := curve.first; level < curve.maximum; level++ {
|
||||
proto := testVarintField(nil, 8, 10)
|
||||
if _, err := db.Exec("INSERT INTO CharLevelTable VALUES (?,?,?)", curve.group, level, proto); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
resource := testVarintField(nil, 9, 10)
|
||||
if _, err := db.Exec("INSERT INTO ResourceTable VALUES (?,?)", 9, resource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
submitted := []PromotionCost{
|
||||
{Type: 8, ID: 9, Count: 40},
|
||||
{Type: 8, ID: 13, Count: 3},
|
||||
{Type: 8, ID: 14, Count: 4},
|
||||
{Type: 4, Count: 7000},
|
||||
}
|
||||
result, err := characterGrowthPromotions(db, 352, 60, 0, submitted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.CharacterID != 354 || result.Level != 100 || result.Exp != 0 || len(result.Refunds) != 0 {
|
||||
t.Fatalf("combined result=%+v", result)
|
||||
}
|
||||
if len(result.Costs) != 4 {
|
||||
t.Fatalf("cumulative costs=%+v", result.Costs)
|
||||
}
|
||||
fromLevelOne := append([]PromotionCost(nil), submitted...)
|
||||
fromLevelOne[0].Count = 99
|
||||
result, err = characterGrowthPromotions(db, 352, 1, 0, fromLevelOne)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.CharacterID != 354 || result.Level != 100 || result.Exp != 0 {
|
||||
t.Fatalf("level-one combined result=%+v", result)
|
||||
}
|
||||
bad := append([]PromotionCost(nil), submitted...)
|
||||
bad[len(bad)-1].Count = 6000
|
||||
if _, err := characterGrowthPromotions(db, 352, 60, 0, bad); err == nil {
|
||||
t.Fatal("non-prefix aggregate gold was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "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. Costume 996000 is the official placeholder
|
||||
// used for an account-owned party slot; callers must not treat it as owned.
|
||||
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)
|
||||
}
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.CreateTemp("", "bd2-quest-formation-*.sqlite")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: create quest database: %w", err)
|
||||
}
|
||||
name := f.Name()
|
||||
defer os.Remove(name)
|
||||
if _, err = f.Write(plain); err == nil {
|
||||
err = f.Close()
|
||||
} else {
|
||||
_ = f.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: write quest database: %w", err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(name)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: open quest database: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
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 rows.Close()
|
||||
|
||||
formations := make(map[int]QuestFormation)
|
||||
charGroups := make(map[uint64][]QuestCharacterDesign)
|
||||
storyGroups := make(map[uint64][]QuestCostumeDesign)
|
||||
for rows.Next() {
|
||||
var questID int
|
||||
var proto []byte
|
||||
if err := rows.Scan(&questID, &proto); err != nil {
|
||||
return nil, fmt.Errorf("gamedata: scan QuestTable%d formation: %w", packID, err)
|
||||
}
|
||||
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 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 rows.Close()
|
||||
var result []QuestCostumeDesign
|
||||
for rows.Next() {
|
||||
var rowID int
|
||||
var proto []byte
|
||||
if err := rows.Scan(&rowID, &proto); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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,108 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestLoadQuestFormationsDB(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "quest.sqlite")
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
for _, statement := range []string{
|
||||
"CREATE TABLE QuestTable21 (id INTEGER PRIMARY KEY, ProtoBuf BLOB NOT NULL)",
|
||||
"CREATE TABLE CharGroupTable (id INTEGER NOT NULL, GroupId INTEGER NOT NULL, ProtoBuf BLOB NOT NULL)",
|
||||
"CREATE TABLE StoryCharGroupTable (id INTEGER NOT NULL, GroupId INTEGER NOT NULL, ProtoBuf BLOB NOT NULL)",
|
||||
"CREATE TABLE CostumeTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB NOT NULL)",
|
||||
} {
|
||||
if _, err := db.Exec(statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
quest := wire.AppendVarint(nil, 4, 2102)
|
||||
quest = wire.AppendVarint(quest, 12, 9)
|
||||
quest = wire.AppendVarint(quest, 12, 7)
|
||||
quest = wire.AppendVarint(quest, 61, 2103)
|
||||
if _, err := db.Exec("INSERT INTO QuestTable21(id,ProtoBuf) VALUES(29,?)", quest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
character := wire.AppendVarint(nil, 1, 10383)
|
||||
character = wire.AppendVarint(character, 2, 2102)
|
||||
character = wire.AppendVarint(character, 3, 1)
|
||||
character = wire.AppendVarint(character, 5, 15)
|
||||
if _, err := db.Exec("INSERT INTO CharGroupTable(id,GroupId,ProtoBuf) VALUES(1,2102,?)", character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
story := wire.AppendVarint(nil, 1, 3801)
|
||||
story = wire.AppendVarint(story, 2, 2103)
|
||||
story = wire.AppendVarint(story, 3, 3)
|
||||
if _, err := db.Exec("INSERT INTO StoryCharGroupTable(id,GroupId,ProtoBuf) VALUES(3,2103,?)", story); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
costume := wire.AppendVarint(nil, 11, 3801)
|
||||
costume = wire.AppendVarint(costume, 27, 38)
|
||||
if _, err := db.Exec("INSERT INTO CostumeTable(id,ProtoBuf) VALUES(3801,?)", costume); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
formations, err := loadQuestFormationsDB(db, 21)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := QuestFormation{
|
||||
QuestID: 29,
|
||||
CharGroupID: 2102,
|
||||
StoryCharGroupID: 2103,
|
||||
DeckList: []uint64{9, 7},
|
||||
Characters: []QuestCharacterDesign{{Order: 1, CharacterID: 10383, Level: 15}},
|
||||
StoryCostumes: []QuestCostumeDesign{{Order: 3, CostumeID: 3801, UniqueCharacterID: 38}},
|
||||
}
|
||||
if got := formations[29]; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("formation mismatch\n got: %#v\nwant: %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealQuest21Formations(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("BD2_REAL_GAMEDATA not configured")
|
||||
}
|
||||
formations, err := LoadQuestFormations(root, "20260910162539", 21)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quest29 := formations[29]
|
||||
if quest29.CharGroupID != 2102 || quest29.StoryCharGroupID != 2103 || len(quest29.DeckList) != 0 {
|
||||
t.Fatalf("unexpected quest 29 groups: %#v", quest29)
|
||||
}
|
||||
wantCharacters := []QuestCharacterDesign{
|
||||
{Order: 1, CharacterID: 10383, Level: 15},
|
||||
{Order: 2, CharacterID: 10372, Level: 15},
|
||||
{Order: 3, CharacterID: 10412, Level: 15},
|
||||
}
|
||||
if !reflect.DeepEqual(quest29.Characters, wantCharacters) {
|
||||
t.Fatalf("quest 29 characters = %#v", quest29.Characters)
|
||||
}
|
||||
wantCostumes := []QuestCostumeDesign{
|
||||
{Order: 1, CostumeID: 996000, UniqueCharacterID: 9960},
|
||||
{Order: 2, CostumeID: 3501, UniqueCharacterID: 35},
|
||||
{Order: 3, CostumeID: 3801, UniqueCharacterID: 38},
|
||||
{Order: 4, CostumeID: 3701, UniqueCharacterID: 37},
|
||||
{Order: 5, CostumeID: 4101, UniqueCharacterID: 41},
|
||||
}
|
||||
if !reflect.DeepEqual(quest29.StoryCostumes, wantCostumes) {
|
||||
t.Fatalf("quest 29 story costumes = %#v", quest29.StoryCostumes)
|
||||
}
|
||||
quest31 := formations[31]
|
||||
if quest31.StoryCharGroupID != 2107 || len(quest31.StoryCostumes) != 3 {
|
||||
t.Fatalf("unexpected quest 31 formation: %#v", quest31)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "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
|
||||
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)
|
||||
}
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-quests-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "quests.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
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 rows.Close()
|
||||
result := make(map[int]QuestDesign)
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
entry := QuestDesign{ID: id}
|
||||
for slot := 0; slot < len(entry.Rewards); slot++ {
|
||||
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,153 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// RandomBoxDesign contains only boxes whose GameData reward graph is wholly
|
||||
// deterministic. The client asks the server to open every RandomBox, but an
|
||||
// offline server must not invent an outcome for weighted boxes. Deterministic
|
||||
// material boxes (including the engraving-scroll and essence boxes) are safe
|
||||
// to resolve directly from the authoritative RewardGroupTable.
|
||||
type RandomBoxDesign struct {
|
||||
rewards map[uint64][]BattleReward
|
||||
}
|
||||
|
||||
// LoadRandomBoxDesign reads RandomBoxTable -> RewardGroupTable from the
|
||||
// installed common GameData. A box is deliberately omitted if its group is
|
||||
// empty, weighted, malformed, cyclic, or resolves to a non-ItemDBInfo reward;
|
||||
// callers then fail closed when a client tries to open it.
|
||||
func LoadRandomBoxDesign(root, version string) (*RandomBoxDesign, error) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-random-box-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
groups := map[uint64][]BattleReward{}
|
||||
rows, err := db.Query("SELECT id, ProtoBuf FROM RewardGroupTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
ids, err := packedInts(raw, 5)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: random box reward group %d item IDs: %w", id, err)
|
||||
}
|
||||
types, err := packedInts(raw, 6)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: random box reward group %d item types: %w", id, err)
|
||||
}
|
||||
counts, err := packedInts(raw, 4)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: random box reward group %d item counts: %w", id, err)
|
||||
}
|
||||
ratios, err := packedInts(raw, 8)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: random box reward group %d ratios: %w", id, err)
|
||||
}
|
||||
// A single reward is deterministic. Ratios are presentation/probability
|
||||
// metadata in that case; a multi-entry group is intentionally not used.
|
||||
if len(ids) != 1 || len(types) != 1 || len(counts) != 1 || ids[0] == 0 || !itemDBInfoRewardType(types[0]) || counts[0] == 0 || (len(ratios) != 0 && len(ratios) != 1) {
|
||||
continue
|
||||
}
|
||||
groups[id] = []BattleReward{{Type: types[0], ID: ids[0], Count: counts[0]}}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
design := &RandomBoxDesign{rewards: map[uint64][]BattleReward{}}
|
||||
rows, err = db.Query("SELECT id, ProtoBuf FROM RandomBoxTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var rowID uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&rowID, &raw); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
ids, err := packedInts(raw, 4)
|
||||
if err != nil || len(ids) != 1 || ids[0] != rowID {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: malformed random box %d", rowID)
|
||||
}
|
||||
groupIDs, err := packedInts(raw, 9)
|
||||
if err != nil || len(groupIDs) != 1 || groupIDs[0] == 0 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: random box %d reward group: %w", rowID, err)
|
||||
}
|
||||
if rewards, ok := groups[groupIDs[0]]; ok {
|
||||
design.rewards[rowID] = append([]BattleReward(nil), rewards...)
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return design, nil
|
||||
}
|
||||
|
||||
// itemDBInfoRewardType is the ItemDBInfo part of DataManager.GetItemInfo for
|
||||
// the static tables this server persists in Inventory. Equipment, characters,
|
||||
// costumes and currency each have a different domain store/response field, so
|
||||
// a RandomBox resolving to one of them is intentionally not opened here.
|
||||
func itemDBInfoRewardType(itemType uint64) bool {
|
||||
switch itemType {
|
||||
case 5, 7, 8, 9, 13, 14, 17, 27, 29:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Open returns the exact aggregate for count opens. It never samples a
|
||||
// weighted group, which avoids silently fabricating a random result.
|
||||
func (d *RandomBoxDesign) Open(boxID, count uint64) ([]BattleReward, error) {
|
||||
if d == nil || count == 0 {
|
||||
return nil, errors.New("gamedata: invalid random box open")
|
||||
}
|
||||
rewards, ok := d.rewards[boxID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("gamedata: random box %d is not deterministic ItemDBInfo loot", boxID)
|
||||
}
|
||||
result := make([]BattleReward, len(rewards))
|
||||
for i, reward := range rewards {
|
||||
if reward.Count > ^uint64(0)/count {
|
||||
return nil, errors.New("gamedata: random box reward count overflow")
|
||||
}
|
||||
result[i] = reward
|
||||
result[i].Count *= count
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRandomBoxOpenAggregatesOnlyKnownDeterministicReward(t *testing.T) {
|
||||
design := &RandomBoxDesign{rewards: map[uint64][]BattleReward{
|
||||
7: {{Type: 8, ID: 704, Count: 1}},
|
||||
}}
|
||||
got, err := design.Open(7, 100000)
|
||||
if err != nil || len(got) != 1 || got[0] != (BattleReward{Type: 8, ID: 704, Count: 100000}) {
|
||||
t.Fatalf("Open deterministic box = %+v, %v", got, err)
|
||||
}
|
||||
if _, err := design.Open(8, 1); err == nil {
|
||||
t.Fatal("accepted an unknown or weighted box")
|
||||
}
|
||||
}
|
||||
|
||||
// This locks the actual 2.34.13 row behind the installed GameData opt-in,
|
||||
// rather than replacing GameData lookup with a hand-written item mapping.
|
||||
func TestLoadRandomBoxDesignInstalledEngravingEssence(t *testing.T) {
|
||||
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
design, err := LoadRandomBoxDesign(root, "20260910162539")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := design.Open(433302, 100000) // Essence of Perseverance box.
|
||||
if err != nil || len(got) != 1 || got[0] != (BattleReward{Type: 8, ID: 704, Count: 100000}) {
|
||||
t.Fatalf("433302 deterministic contents = %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// EquipmentOption identifies the exact server-owned option stored in
|
||||
// EquipBaseInfo. Rank values use Define_EquipRankType: 0=None, 1=C ... 4=S.
|
||||
type EquipmentOption struct {
|
||||
GroupID uint64
|
||||
ID uint64
|
||||
Level int
|
||||
Rank [3]int
|
||||
}
|
||||
|
||||
type characterStatBase struct {
|
||||
GrowthID uint64
|
||||
Base BaseStats
|
||||
}
|
||||
|
||||
// CharacterStatDesign is the immutable, in-memory subset of GameData needed
|
||||
// to resolve character level stats. Keeping the complete curve beside the
|
||||
// other loaded design data prevents request handlers from decrypting and
|
||||
// materializing the roughly 180 MiB common database once per character.
|
||||
type CharacterStatDesign struct {
|
||||
characters map[uint64]characterStatBase
|
||||
growthGroups map[uint64]uint64
|
||||
levelRatios map[[2]uint64]BaseStats
|
||||
}
|
||||
|
||||
// BaseStats resolves one character and level without filesystem or database
|
||||
// access. CharacterStatDesign is immutable after loading and is therefore
|
||||
// safe for concurrent request handlers.
|
||||
func (d *CharacterStatDesign) BaseStats(charID, level uint64) (BaseStats, error) {
|
||||
if d == nil || charID == 0 || level == 0 {
|
||||
return BaseStats{}, fmt.Errorf("gamedata: invalid character stat input")
|
||||
}
|
||||
character, found := d.characters[charID]
|
||||
if !found {
|
||||
return BaseStats{}, fmt.Errorf("gamedata: character stats %d: %w", charID, sql.ErrNoRows)
|
||||
}
|
||||
group, found := d.growthGroups[character.GrowthID]
|
||||
if !found {
|
||||
return BaseStats{}, fmt.Errorf("gamedata: character %d growth id %d: %w", charID, character.GrowthID, sql.ErrNoRows)
|
||||
}
|
||||
ratio, found := d.levelRatios[[2]uint64{group, level}]
|
||||
if !found {
|
||||
return BaseStats{}, fmt.Errorf("gamedata: character level group=%d level=%d: %w", group, level, sql.ErrNoRows)
|
||||
}
|
||||
return applyCharacterLevel(character.Base, ratio), nil
|
||||
}
|
||||
|
||||
func applyCharacterLevel(base, ratio BaseStats) BaseStats {
|
||||
return BaseStats{
|
||||
Health: math.Trunc(base.Health * (1 + ratio.Health)),
|
||||
Attack: math.Trunc(base.Attack * (1 + ratio.Attack)),
|
||||
Magic: math.Trunc(base.Magic * (1 + ratio.Magic)),
|
||||
}
|
||||
}
|
||||
|
||||
func loadCharacterStatDesign(db *sql.DB) (*CharacterStatDesign, error) {
|
||||
design := &CharacterStatDesign{
|
||||
characters: make(map[uint64]characterStatBase),
|
||||
growthGroups: make(map[uint64]uint64),
|
||||
levelRatios: make(map[[2]uint64]BaseStats),
|
||||
}
|
||||
rows, err := db.Query("SELECT id, ProtoBuf FROM CharTable")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: query character stats: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
growthIDs, err := packedInts(proto, 1)
|
||||
if err != nil || len(growthIDs) != 1 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: character %d growth id %v: %w", id, growthIDs, err)
|
||||
}
|
||||
health, found, err := fixed64Double(proto, 11)
|
||||
if err != nil || !found {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: character %d health: %w", id, err)
|
||||
}
|
||||
attack, _, err := fixed64Double(proto, 17)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: character %d attack: %w", id, err)
|
||||
}
|
||||
magic, _, err := fixed64Double(proto, 14)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: character %d magic: %w", id, err)
|
||||
}
|
||||
design.characters[id] = characterStatBase{GrowthID: growthIDs[0], Base: BaseStats{Health: health, Attack: attack, Magic: magic}}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = db.Query("SELECT id, ProtoBuf FROM CharGrowthTable")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: query character growths: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
groups, err := packedInts(proto, 1)
|
||||
if err != nil || len(groups) != 1 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: character growth %d group %v: %w", id, groups, err)
|
||||
}
|
||||
design.growthGroups[id] = groups[0]
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = db.Query("SELECT GroupId, id, ProtoBuf FROM CharLevelTable")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: query character levels: %w", 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
|
||||
}
|
||||
health, _, err := fixed64Double(proto, 6)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: character level %d/%d health: %w", group, level, err)
|
||||
}
|
||||
attack, _, err := fixed64Double(proto, 12)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: character level %d/%d attack: %w", group, level, err)
|
||||
}
|
||||
magic, _, err := fixed64Double(proto, 10)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: character level %d/%d magic: %w", group, level, err)
|
||||
}
|
||||
design.levelRatios[[2]uint64{group, level}] = BaseStats{Health: health, Attack: attack, Magic: magic}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return design, nil
|
||||
}
|
||||
|
||||
// CharacterBaseStats reads CharTable -> CharGrowthTable -> CharLevelTable and
|
||||
// applies the client's level formula. It returns design stats only; account
|
||||
// systems such as equipment and costumes are aggregated separately.
|
||||
func CharacterBaseStats(root, version string, charID, level int) (BaseStats, error) {
|
||||
if charID <= 0 || level <= 0 {
|
||||
return BaseStats{}, fmt.Errorf("gamedata: invalid character stat input")
|
||||
}
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return BaseStats{}, err
|
||||
}
|
||||
defer closeDB()
|
||||
var character []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", charID).Scan(&character); err != nil {
|
||||
return BaseStats{}, fmt.Errorf("gamedata: character stats %d: %w", charID, err)
|
||||
}
|
||||
growthIDs, err := packedInts(character, 1)
|
||||
if err != nil || len(growthIDs) != 1 {
|
||||
return BaseStats{}, fmt.Errorf("gamedata: character %d growth id %v: %w", charID, growthIDs, err)
|
||||
}
|
||||
var growth []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthIDs[0]).Scan(&growth); err != nil {
|
||||
return BaseStats{}, err
|
||||
}
|
||||
groups, err := packedInts(growth, 1)
|
||||
if err != nil || len(groups) != 1 {
|
||||
return BaseStats{}, fmt.Errorf("gamedata: character growth group %v: %w", groups, err)
|
||||
}
|
||||
var levelProto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharLevelTable WHERE GroupId=? AND id=?", groups[0], level).Scan(&levelProto); err != nil {
|
||||
return BaseStats{}, fmt.Errorf("gamedata: character level group=%d level=%d: %w", groups[0], level, err)
|
||||
}
|
||||
baseHP, ok, err := fixed64Double(character, 11)
|
||||
if err != nil || !ok {
|
||||
return BaseStats{}, fmt.Errorf("gamedata: character %d health: %w", charID, err)
|
||||
}
|
||||
baseAttack, _, err := fixed64Double(character, 17)
|
||||
if err != nil {
|
||||
return BaseStats{}, err
|
||||
}
|
||||
baseMagic, _, err := fixed64Double(character, 14)
|
||||
if err != nil {
|
||||
return BaseStats{}, err
|
||||
}
|
||||
hpRatio, _, err := fixed64Double(levelProto, 6)
|
||||
if err != nil {
|
||||
return BaseStats{}, err
|
||||
}
|
||||
attackRatio, _, err := fixed64Double(levelProto, 12)
|
||||
if err != nil {
|
||||
return BaseStats{}, err
|
||||
}
|
||||
magicRatio, _, err := fixed64Double(levelProto, 10)
|
||||
if err != nil {
|
||||
return BaseStats{}, err
|
||||
}
|
||||
return applyCharacterLevel(
|
||||
BaseStats{Health: baseHP, Attack: baseAttack, Magic: baseMagic},
|
||||
BaseStats{Health: hpRatio, Attack: attackRatio, Magic: magicRatio},
|
||||
), nil
|
||||
}
|
||||
|
||||
// EquipmentOptionContribution resolves an EquipOptionInfo through the real
|
||||
// EquipmentOptionTable, including level and three rank breakpoints.
|
||||
func EquipmentOptionContribution(root, version string, option EquipmentOption) (StatContribution, error) {
|
||||
if option.GroupID == 0 || option.ID == 0 || option.Level < 0 {
|
||||
return StatContribution{}, fmt.Errorf("gamedata: invalid equipment option")
|
||||
}
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return StatContribution{}, err
|
||||
}
|
||||
defer closeDB()
|
||||
var proto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM EquipmentOptionTable WHERE GroupId=? AND id=?", option.GroupID, option.ID).Scan(&proto); err != nil {
|
||||
return StatContribution{}, fmt.Errorf("gamedata: equipment option %d/%d: %w", option.GroupID, option.ID, err)
|
||||
}
|
||||
defaultValue, _, err := fixed64Double(proto, 1)
|
||||
if err != nil {
|
||||
return StatContribution{}, err
|
||||
}
|
||||
growthValue, _, err := fixed64Double(proto, 4)
|
||||
if err != nil {
|
||||
return StatContribution{}, err
|
||||
}
|
||||
levels, err := fixed32Floats(proto, 6)
|
||||
if err != nil {
|
||||
return StatContribution{}, err
|
||||
}
|
||||
if len(levels) != 0 && option.Level >= len(levels) {
|
||||
return StatContribution{}, fmt.Errorf("gamedata: equipment level %d outside option curve", option.Level)
|
||||
}
|
||||
levelValue := 0.0
|
||||
if len(levels) != 0 {
|
||||
levelValue = levels[option.Level]
|
||||
}
|
||||
rankValue := 0.0
|
||||
for i, field := range []int{7, 8, 9} {
|
||||
values, err := fixed32Floats(proto, field)
|
||||
if err != nil {
|
||||
return StatContribution{}, err
|
||||
}
|
||||
rank := option.Rank[i]
|
||||
if rank == 0 {
|
||||
continue
|
||||
}
|
||||
if rank < 1 || rank > len(values) {
|
||||
return StatContribution{}, fmt.Errorf("gamedata: invalid equipment rank %d", rank)
|
||||
}
|
||||
rankValue += values[rank-1]
|
||||
}
|
||||
return optionContribution(option.ID, defaultValue+growthValue*(levelValue+rankValue))
|
||||
}
|
||||
|
||||
func optionContribution(id uint64, value float64) (StatContribution, error) {
|
||||
percentage := id != 1 && id != 3 && id != 5
|
||||
digits := 0
|
||||
if percentage {
|
||||
digits = 4
|
||||
}
|
||||
factor := math.Pow10(digits)
|
||||
value = math.Round(value*factor) / factor
|
||||
contribution := StatContribution{}
|
||||
switch id {
|
||||
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
|
||||
default:
|
||||
return StatContribution{}, fmt.Errorf("gamedata: unsupported equipment stat option %d", id)
|
||||
}
|
||||
if percentage {
|
||||
contribution.Percent = value / 100
|
||||
} else {
|
||||
contribution.Flat = value
|
||||
}
|
||||
return contribution, nil
|
||||
}
|
||||
|
||||
func fixed32Floats(proto []byte, number int) ([]float64, error) {
|
||||
var values []float64
|
||||
err := wire.Walk(proto, func(field wire.Field) error {
|
||||
if field.Number != number {
|
||||
return nil
|
||||
}
|
||||
if field.Type == 5 {
|
||||
values = append(values, float64(math.Float32frombits(binary.LittleEndian.Uint32(field.Value))))
|
||||
return nil
|
||||
}
|
||||
if field.Type != 2 || len(field.Value)%4 != 0 {
|
||||
return fmt.Errorf("field %d invalid float encoding", number)
|
||||
}
|
||||
for data := field.Value; len(data) != 0; data = data[4:] {
|
||||
values = append(values, float64(math.Float32frombits(binary.LittleEndian.Uint32(data))))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return values, err
|
||||
}
|
||||
|
||||
func openStatDatabase(root, version string) (*sql.DB, func(), error) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-stats-")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return nil, nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return nil, nil, err
|
||||
}
|
||||
return db, func() { _ = db.Close(); _ = os.RemoveAll(dir) }, nil
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// CharacterTalent identifies the GameData rows which govern one character's
|
||||
// talent. MaxLevel is deliberately read per talent: not every talent has five
|
||||
// levels.
|
||||
type CharacterTalent struct {
|
||||
TalentID uint64
|
||||
GrowthGroup uint64
|
||||
MaxLevel uint64
|
||||
}
|
||||
|
||||
// TalentGrowthLevel describes the upgrade from Level to Level+1. NeedExp is
|
||||
// the experience earned within this level; player talent experience itself is
|
||||
// cumulative and is not reset by an upgrade.
|
||||
type TalentGrowthLevel struct {
|
||||
Level uint64
|
||||
NeedExp uint64
|
||||
Costs []PromotionCost
|
||||
}
|
||||
|
||||
type TalentUpgradeRule struct {
|
||||
CharacterID uint64
|
||||
TalentID uint64
|
||||
GrowthGroup uint64
|
||||
CurrentLevel uint64
|
||||
MaxLevel uint64
|
||||
RequiredTotalExp uint64
|
||||
Costs []PromotionCost
|
||||
}
|
||||
|
||||
// TalentGrowthDesign joins CharTable -> TalentTable -> TalentGrowthTable so
|
||||
// the runtime never guesses a growth group or hard-codes skill-book IDs.
|
||||
type TalentGrowthDesign struct {
|
||||
Characters map[uint64]CharacterTalent
|
||||
Levels map[[2]uint64]TalentGrowthLevel
|
||||
}
|
||||
|
||||
func LoadTalentGrowthDesign(root, version string) (*TalentGrowthDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
return loadTalentGrowthDesign(db)
|
||||
}
|
||||
|
||||
func loadTalentGrowthDesign(db *sql.DB) (*TalentGrowthDesign, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("gamedata: nil talent growth database")
|
||||
}
|
||||
talents := make(map[uint64]CharacterTalent)
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM TalentTable 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
|
||||
}
|
||||
groups, err := packedInts(raw, 6)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: talent %d growth group: %w", id, err)
|
||||
}
|
||||
maximum, err := packedInts(raw, 11)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: talent %d maximum level: %w", id, err)
|
||||
}
|
||||
if len(groups) != 1 || groups[0] == 0 || len(maximum) != 1 || maximum[0] == 0 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: talent %d has invalid growth metadata", id)
|
||||
}
|
||||
talents[id] = CharacterTalent{TalentID: id, GrowthGroup: groups[0], MaxLevel: maximum[0]}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
design := &TalentGrowthDesign{
|
||||
Characters: make(map[uint64]CharacterTalent),
|
||||
Levels: make(map[[2]uint64]TalentGrowthLevel),
|
||||
}
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var characterID uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&characterID, &raw); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
talentIDs, err := packedInts(raw, 18)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: character %d talent: %w", characterID, err)
|
||||
}
|
||||
if len(talentIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(talentIDs) != 1 || talentIDs[0] == 0 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: character %d has invalid talent reference", characterID)
|
||||
}
|
||||
talent, found := talents[talentIDs[0]]
|
||||
if !found {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: character %d references unknown talent %d", characterID, talentIDs[0])
|
||||
}
|
||||
design.Characters[characterID] = 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 TalentGrowthTable ORDER BY groupId,id")
|
||||
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
|
||||
}
|
||||
counts, countErr := packedInts(raw, 2)
|
||||
ids, idErr := packedInts(raw, 3)
|
||||
types, typeErr := packedInts(raw, 4)
|
||||
needs, needErr := packedInts(raw, 6)
|
||||
if countErr != nil || idErr != nil || typeErr != nil || needErr != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: talent growth %d/%d has invalid fields", group, level)
|
||||
}
|
||||
if group == 0 || level == 0 || len(counts) != len(ids) || len(ids) != len(types) || len(needs) > 1 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: talent growth %d/%d has mismatched fields", group, level)
|
||||
}
|
||||
entry := TalentGrowthLevel{Level: level}
|
||||
if len(needs) == 1 {
|
||||
entry.NeedExp = needs[0]
|
||||
}
|
||||
for i := range counts {
|
||||
if types[i] == 0 || counts[i] == 0 || (types[i] == 4 && ids[i] != 0) || (types[i] != 4 && ids[i] == 0) {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: talent growth %d/%d has invalid cost", group, level)
|
||||
}
|
||||
entry.Costs = append(entry.Costs, PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]})
|
||||
}
|
||||
key := [2]uint64{group, level}
|
||||
if _, exists := design.Levels[key]; exists {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: duplicate talent growth %d/%d", group, level)
|
||||
}
|
||||
design.Levels[key] = entry
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(design.Characters) == 0 || len(design.Levels) == 0 {
|
||||
return nil, errors.New("gamedata: empty talent growth design")
|
||||
}
|
||||
validated := make(map[uint64]bool)
|
||||
for _, talent := range design.Characters {
|
||||
if validated[talent.TalentID] {
|
||||
continue
|
||||
}
|
||||
validated[talent.TalentID] = true
|
||||
for level := uint64(1); level < talent.MaxLevel; level++ {
|
||||
entry, ok := design.Levels[[2]uint64{talent.GrowthGroup, level}]
|
||||
if !ok || entry.NeedExp == 0 || len(entry.Costs) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: talent %d missing upgrade level %d", talent.TalentID, level)
|
||||
}
|
||||
}
|
||||
}
|
||||
return design, nil
|
||||
}
|
||||
|
||||
// UpgradeRule returns the exact current-level requirement and cumulative
|
||||
// experience threshold. It intentionally refuses max-level characters.
|
||||
func (d *TalentGrowthDesign) UpgradeRule(characterID, currentLevel uint64) (TalentUpgradeRule, error) {
|
||||
if d == nil || characterID == 0 || currentLevel == 0 {
|
||||
return TalentUpgradeRule{}, errors.New("gamedata: invalid talent upgrade lookup")
|
||||
}
|
||||
talent, found := d.Characters[characterID]
|
||||
if !found {
|
||||
return TalentUpgradeRule{}, fmt.Errorf("gamedata: character %d has no talent growth design", characterID)
|
||||
}
|
||||
if currentLevel >= talent.MaxLevel {
|
||||
return TalentUpgradeRule{}, fmt.Errorf("gamedata: character %d talent is already at maximum level %d", characterID, talent.MaxLevel)
|
||||
}
|
||||
var required uint64
|
||||
for level := uint64(1); level <= currentLevel; level++ {
|
||||
entry, ok := d.Levels[[2]uint64{talent.GrowthGroup, level}]
|
||||
if !ok || entry.NeedExp == 0 {
|
||||
return TalentUpgradeRule{}, fmt.Errorf("gamedata: talent %d missing growth level %d", talent.TalentID, level)
|
||||
}
|
||||
if required > math.MaxUint64-entry.NeedExp {
|
||||
return TalentUpgradeRule{}, fmt.Errorf("gamedata: talent %d experience threshold overflows", talent.TalentID)
|
||||
}
|
||||
required += entry.NeedExp
|
||||
}
|
||||
current := d.Levels[[2]uint64{talent.GrowthGroup, currentLevel}]
|
||||
if len(current.Costs) == 0 {
|
||||
return TalentUpgradeRule{}, fmt.Errorf("gamedata: talent %d level %d has no upgrade costs", talent.TalentID, currentLevel)
|
||||
}
|
||||
return TalentUpgradeRule{
|
||||
CharacterID: characterID, TalentID: talent.TalentID, GrowthGroup: talent.GrowthGroup,
|
||||
CurrentLevel: currentLevel, MaxLevel: talent.MaxLevel, RequiredTotalExp: required,
|
||||
Costs: append([]PromotionCost(nil), current.Costs...),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestTalentGrowthDesignJoinsCharacterAndUsesCumulativeExperience(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec("CREATE TABLE TalentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE CharTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE TalentGrowthTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
talent := testVarintField(testVarintField(nil, 6, 904), 11, 5)
|
||||
character := testVarintField(nil, 18, 904)
|
||||
if _, err := db.Exec("INSERT INTO TalentTable VALUES (?,?)", 904, talent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO CharTable VALUES (?,?)", 140, character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
needs := []uint64{14, 28, 404, 1008}
|
||||
gold := []uint64{1000, 2000, 4000, 10000}
|
||||
for level, need := range needs {
|
||||
growth := testPackedField(nil, 2, 1, gold[level])
|
||||
growth = testPackedField(growth, 3, uint64(level+3), 0)
|
||||
growth = testPackedField(growth, 4, 8, 4)
|
||||
growth = testVarintField(growth, 6, need)
|
||||
if _, err := db.Exec("INSERT INTO TalentGrowthTable VALUES (?,?,?)", 904, level+1, growth); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO TalentGrowthTable VALUES (?,?,?)", 904, 5, testVarintField(nil, 5, 5)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design, err := loadTalentGrowthDesign(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule, err := design.UpgradeRule(140, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rule.TalentID != 904 || rule.GrowthGroup != 904 || rule.MaxLevel != 5 || rule.RequiredTotalExp != 446 {
|
||||
t.Fatalf("rule=%+v", rule)
|
||||
}
|
||||
want := []PromotionCost{{Type: 8, ID: 5, Count: 1}, {Type: 4, Count: 4000}}
|
||||
if len(rule.Costs) != len(want) || rule.Costs[0] != want[0] || rule.Costs[1] != want[1] {
|
||||
t.Fatalf("costs=%+v want=%+v", rule.Costs, want)
|
||||
}
|
||||
if _, err := design.UpgradeRule(140, 5); err == nil {
|
||||
t.Fatal("max-level talent was upgradeable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTalentGrowthDesignRejectsMismatchedCostArrays(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec("CREATE TABLE TalentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE CharTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE TalentGrowthTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
talent := testVarintField(testVarintField(nil, 6, 9), 11, 2)
|
||||
character := testVarintField(nil, 18, 9)
|
||||
broken := testPackedField(testPackedField(nil, 2, 1, 1000), 3, 3)
|
||||
broken = testPackedField(broken, 4, 8, 4)
|
||||
broken = wire.AppendVarint(broken, 6, 10)
|
||||
if _, err := db.Exec("INSERT INTO TalentTable VALUES (?,?)", 9, talent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO CharTable VALUES (?,?)", 1, character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO TalentGrowthTable VALUES (?,?,?)", 9, 1, broken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadTalentGrowthDesign(db); err == nil {
|
||||
t.Fatal("mismatched talent costs were accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTalentGrowthAgainstInstalledCurrentVersion(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test")
|
||||
}
|
||||
design, err := LoadTalentGrowthDesign(root, "20260923193640")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule, err := design.UpgradeRule(140, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []PromotionCost{{Type: 8, ID: 3, Count: 1}, {Type: 4, Count: 1000}}
|
||||
if rule.TalentID != 904 || rule.MaxLevel != 5 || rule.RequiredTotalExp != 14 || len(rule.Costs) != 2 || rule.Costs[0] != want[0] || rule.Costs[1] != want[1] {
|
||||
t.Fatalf("installed rule=%+v", rule)
|
||||
}
|
||||
if _, err := design.UpgradeRule(10140, 1); err == nil {
|
||||
t.Fatal("the current max-level-one talent was upgradeable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// Package gamedata validates the versioned archive served to the client.
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ArchiveName = "common-dbdata.bin"
|
||||
InfoName = "common-dbdata.info"
|
||||
)
|
||||
|
||||
// Result describes a completely readable GameData archive. Validation reads
|
||||
// every entry to EOF, which makes archive/zip verify each entry's CRC32.
|
||||
type Result struct {
|
||||
ArchivePath string
|
||||
ArchiveSize int64
|
||||
EntryCount int
|
||||
UncompressedSize int64
|
||||
}
|
||||
|
||||
// Ensure returns a verified local archive. If local validation fails, it
|
||||
// downloads the official size metadata and archive into staging files,
|
||||
// validates the complete ZIP (including every entry CRC), and only then
|
||||
// replaces the bad files. Existing files are preserved as .broken.* backups.
|
||||
func Ensure(ctx context.Context, client *http.Client, root, version, origin string) (Result, bool, error) {
|
||||
if result, err := Validate(root, version); err == nil {
|
||||
return result, false, nil
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 30 * time.Minute}
|
||||
}
|
||||
origin = strings.TrimRight(origin, "/")
|
||||
if origin == "" {
|
||||
return Result{}, false, fmt.Errorf("gamedata: download origin is empty")
|
||||
}
|
||||
release := filepath.Join(root, version, "release")
|
||||
if err := os.MkdirAll(release, 0o755); err != nil {
|
||||
return Result{}, false, fmt.Errorf("gamedata: create release directory: %w", err)
|
||||
}
|
||||
baseURL := origin + "/" + version + "/release/"
|
||||
|
||||
infoBytes, err := downloadSmall(ctx, client, baseURL+InfoName, 1024)
|
||||
if err != nil {
|
||||
return Result{}, false, err
|
||||
}
|
||||
expected, err := parseExpectedSize(infoBytes)
|
||||
if err != nil {
|
||||
return Result{}, false, err
|
||||
}
|
||||
|
||||
archiveStage, err := os.CreateTemp(release, ".common-dbdata-*.tmp")
|
||||
if err != nil {
|
||||
return Result{}, false, fmt.Errorf("gamedata: create archive staging file: %w", err)
|
||||
}
|
||||
archiveStagePath := archiveStage.Name()
|
||||
keepStage := false
|
||||
defer func() {
|
||||
_ = archiveStage.Close()
|
||||
if !keepStage {
|
||||
_ = os.Remove(archiveStagePath)
|
||||
}
|
||||
}()
|
||||
if err := downloadExact(ctx, client, baseURL+ArchiveName, archiveStage, expected); err != nil {
|
||||
return Result{}, false, err
|
||||
}
|
||||
if err := archiveStage.Sync(); err != nil {
|
||||
return Result{}, false, fmt.Errorf("gamedata: sync staged archive: %w", err)
|
||||
}
|
||||
if err := archiveStage.Close(); err != nil {
|
||||
return Result{}, false, fmt.Errorf("gamedata: close staged archive: %w", err)
|
||||
}
|
||||
verified, err := validateArchive(archiveStagePath, expected)
|
||||
if err != nil {
|
||||
return Result{}, false, fmt.Errorf("gamedata: downloaded archive failed validation: %w", err)
|
||||
}
|
||||
|
||||
infoStage, err := os.CreateTemp(release, ".common-dbdata-info-*.tmp")
|
||||
if err != nil {
|
||||
return Result{}, false, fmt.Errorf("gamedata: create info staging file: %w", err)
|
||||
}
|
||||
infoStagePath := infoStage.Name()
|
||||
defer os.Remove(infoStagePath)
|
||||
if _, err := infoStage.Write(infoBytes); err != nil {
|
||||
infoStage.Close()
|
||||
return Result{}, false, fmt.Errorf("gamedata: write staged info: %w", err)
|
||||
}
|
||||
if err := infoStage.Sync(); err != nil {
|
||||
infoStage.Close()
|
||||
return Result{}, false, fmt.Errorf("gamedata: sync staged info: %w", err)
|
||||
}
|
||||
if err := infoStage.Close(); err != nil {
|
||||
return Result{}, false, fmt.Errorf("gamedata: close staged info: %w", err)
|
||||
}
|
||||
|
||||
archivePath := filepath.Join(release, ArchiveName)
|
||||
infoPath := filepath.Join(release, InfoName)
|
||||
stamp := time.Now().UTC().Format("20060102T150405.000000000Z")
|
||||
archiveBackup, archiveMoved, err := preserveExisting(archivePath, stamp)
|
||||
if err != nil {
|
||||
return Result{}, false, err
|
||||
}
|
||||
infoBackup, infoMoved, err := preserveExisting(infoPath, stamp)
|
||||
if err != nil {
|
||||
if archiveMoved {
|
||||
_ = os.Rename(archiveBackup, archivePath)
|
||||
}
|
||||
return Result{}, false, err
|
||||
}
|
||||
rollback := func() {
|
||||
_ = os.Remove(archivePath)
|
||||
_ = os.Remove(infoPath)
|
||||
if archiveMoved {
|
||||
_ = os.Rename(archiveBackup, archivePath)
|
||||
}
|
||||
if infoMoved {
|
||||
_ = os.Rename(infoBackup, infoPath)
|
||||
}
|
||||
}
|
||||
if err := os.Rename(archiveStagePath, archivePath); err != nil {
|
||||
rollback()
|
||||
return Result{}, false, fmt.Errorf("gamedata: install verified archive: %w", err)
|
||||
}
|
||||
keepStage = true
|
||||
if err := os.Rename(infoStagePath, infoPath); err != nil {
|
||||
rollback()
|
||||
return Result{}, false, fmt.Errorf("gamedata: install size metadata: %w", err)
|
||||
}
|
||||
|
||||
verified.ArchivePath = archivePath
|
||||
return verified, true, nil
|
||||
}
|
||||
|
||||
func Validate(root, version string) (Result, error) {
|
||||
if root == "" || version == "" || strings.ContainsAny(version, `/\\`) {
|
||||
return Result{}, fmt.Errorf("gamedata: invalid root or version")
|
||||
}
|
||||
release := filepath.Join(root, version, "release")
|
||||
archivePath := filepath.Join(release, ArchiveName)
|
||||
infoPath := filepath.Join(release, InfoName)
|
||||
|
||||
infoBytes, err := os.ReadFile(infoPath)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("gamedata: read size metadata: %w", err)
|
||||
}
|
||||
expected, err := parseExpectedSize(infoBytes)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
stat, err := os.Stat(archivePath)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("gamedata: stat archive: %w", err)
|
||||
}
|
||||
if stat.Size() != expected {
|
||||
return Result{}, fmt.Errorf("gamedata: archive size %d does not match metadata %d", stat.Size(), expected)
|
||||
}
|
||||
|
||||
return validateArchive(archivePath, expected)
|
||||
}
|
||||
|
||||
func validateArchive(archivePath string, expected int64) (Result, error) {
|
||||
stat, err := os.Stat(archivePath)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("gamedata: stat archive: %w", err)
|
||||
}
|
||||
if stat.Size() != expected {
|
||||
return Result{}, fmt.Errorf("gamedata: archive size %d does not match metadata %d", stat.Size(), expected)
|
||||
}
|
||||
reader, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("gamedata: open archive: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
if len(reader.File) == 0 {
|
||||
return Result{}, fmt.Errorf("gamedata: archive is empty")
|
||||
}
|
||||
|
||||
var unpacked int64
|
||||
for index, entry := range reader.File {
|
||||
stream, err := entry.Open()
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("gamedata: open entry %d %q: %w", index, entry.Name, err)
|
||||
}
|
||||
read, copyErr := io.Copy(io.Discard, stream)
|
||||
closeErr := stream.Close()
|
||||
if copyErr != nil {
|
||||
return Result{}, fmt.Errorf("gamedata: verify entry %d %q: %w", index, entry.Name, copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return Result{}, fmt.Errorf("gamedata: close entry %d %q: %w", index, entry.Name, closeErr)
|
||||
}
|
||||
if read != int64(entry.UncompressedSize64) {
|
||||
return Result{}, fmt.Errorf("gamedata: entry %d %q size %d, expected %d", index, entry.Name, read, entry.UncompressedSize64)
|
||||
}
|
||||
unpacked += read
|
||||
}
|
||||
|
||||
return Result{
|
||||
ArchivePath: archivePath,
|
||||
ArchiveSize: stat.Size(),
|
||||
EntryCount: len(reader.File),
|
||||
UncompressedSize: unpacked,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseExpectedSize(infoBytes []byte) (int64, error) {
|
||||
text := strings.TrimSpace(string(infoBytes))
|
||||
expected, err := strconv.ParseInt(text, 10, 64)
|
||||
if err != nil || expected <= 0 {
|
||||
return 0, fmt.Errorf("gamedata: invalid size metadata %q", text)
|
||||
}
|
||||
return expected, nil
|
||||
}
|
||||
|
||||
func downloadSmall(ctx context.Context, client *http.Client, url string, limit int64) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: build metadata request: %w", err)
|
||||
}
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: download %s: %w", InfoName, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("gamedata: download %s: HTTP %s", InfoName, response.Status)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: read %s: %w", InfoName, err)
|
||||
}
|
||||
if int64(len(b)) > limit {
|
||||
return nil, fmt.Errorf("gamedata: %s exceeds %d bytes", InfoName, limit)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func downloadExact(ctx context.Context, client *http.Client, url string, target *os.File, expected int64) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: build archive request: %w", err)
|
||||
}
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: download %s: %w", ArchiveName, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("gamedata: download %s: HTTP %s", ArchiveName, response.Status)
|
||||
}
|
||||
written, err := io.Copy(target, io.LimitReader(response.Body, expected+1))
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: write staged archive: %w", err)
|
||||
}
|
||||
if written != expected {
|
||||
return fmt.Errorf("gamedata: downloaded archive size %d, expected %d", written, expected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func preserveExisting(path, stamp string) (string, bool, error) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, fmt.Errorf("gamedata: inspect existing file %q: %w", path, err)
|
||||
}
|
||||
backup := path + ".broken." + stamp
|
||||
if err := os.Rename(path, backup); err != nil {
|
||||
return "", false, fmt.Errorf("gamedata: preserve invalid file %q: %w", path, err)
|
||||
}
|
||||
return backup, true, nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
release := filepath.Join(root, "123", "release")
|
||||
if err := os.MkdirAll(release, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive := filepath.Join(release, ArchiveName)
|
||||
f, err := os.Create(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := zip.NewWriter(f)
|
||||
entry, err := w.Create("common-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := entry.Write([]byte("verified payload")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stat, err := os.Stat(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(release, InfoName), []byte(strconv.FormatInt(stat.Size(), 10)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := Validate(root, "123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.EntryCount != 1 || result.UncompressedSize != int64(len("verified payload")) {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsSizeMismatch(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
release := filepath.Join(root, "123", "release")
|
||||
if err := os.MkdirAll(release, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(release, ArchiveName), []byte("not a zip"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(release, InfoName), []byte("999"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Validate(root, "123"); err == nil {
|
||||
t.Fatal("expected validation failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDownloadsAndPreservesBrokenArchive(t *testing.T) {
|
||||
served := t.TempDir()
|
||||
writeTestArchive(t, served, "123", []byte("healthy archive payload"))
|
||||
origin := httptest.NewServer(http.FileServer(http.Dir(served)))
|
||||
defer origin.Close()
|
||||
|
||||
root := t.TempDir()
|
||||
release := filepath.Join(root, "123", "release")
|
||||
if err := os.MkdirAll(release, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(release, ArchiveName), []byte("broken"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(release, InfoName), []byte("6"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, downloaded, err := Ensure(context.Background(), origin.Client(), root, "123", origin.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !downloaded || result.EntryCount != 1 {
|
||||
t.Fatalf("unexpected ensure result: downloaded=%v result=%+v", downloaded, result)
|
||||
}
|
||||
broken, err := filepath.Glob(filepath.Join(release, ArchiveName+".broken.*"))
|
||||
if err != nil || len(broken) != 1 {
|
||||
t.Fatalf("broken archive was not preserved: %v %v", broken, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestArchive(t *testing.T, root, version string, payload []byte) {
|
||||
t.Helper()
|
||||
release := filepath.Join(root, version, "release")
|
||||
if err := os.MkdirAll(release, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive := filepath.Join(release, ArchiveName)
|
||||
f, err := os.Create(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := zip.NewWriter(f)
|
||||
entry, err := w.Create("common-hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := entry.Write(payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stat, err := os.Stat(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(release, InfoName), []byte(strconv.FormatInt(stat.Size(), 10)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user