feat: split client tooling and add OAuth server login

This commit is contained in:
2026-09-30 17:32:14 +08:00
parent 7cb5e36c2a
commit 2d054f6b72
230 changed files with 9875 additions and 853 deletions
+72
View File
@@ -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)
}
}
+119
View File
@@ -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)
}
}
+397
View File
@@ -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)
}
}
+79
View File
@@ -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])
}
}
}
+187
View File
@@ -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)
}
}
+274
View File
@@ -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
}
+306
View File
@@ -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
}
+353
View File
@@ -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(&current); err != nil {
return 0, nil, fmt.Errorf("gamedata: promotion character %d: %w", charID, err)
}
next, err := packedInts(current, 15)
if err != nil || len(next) != 1 || next[0] == 0 {
return 0, nil, fmt.Errorf("gamedata: character %d has no next stage", charID)
}
growthID, err := packedInts(current, 1)
if err != nil || len(growthID) != 1 {
return 0, nil, fmt.Errorf("gamedata: character %d has no growth definition", charID)
}
var growth []byte
if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthID[0]).Scan(&growth); err != nil {
return 0, nil, err
}
maximum, _ := packedInts(growth, 9)
group, _ := packedInts(growth, 1)
if len(maximum) != 1 || len(group) != 1 || level != maximum[0] {
return 0, nil, fmt.Errorf("gamedata: character %d has not reached its promotion level", charID)
}
var curve []byte
if err := db.QueryRow("SELECT ProtoBuf FROM CharLevelTable WHERE GroupId=? AND id=?", group[0], level).Scan(&curve); err != nil {
return 0, nil, err
}
required, _ := packedInts(curve, 8)
if len(required) != 0 && exp < required[0] {
return 0, nil, fmt.Errorf("gamedata: character %d has not reached its promotion experience", charID)
}
var nextProto []byte
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", next[0]).Scan(&nextProto); err != nil {
return 0, nil, fmt.Errorf("gamedata: next character %d: %w", next[0], err)
}
nextGrowth, _ := packedInts(nextProto, 1)
if len(nextGrowth) != 1 || nextGrowth[0] == growthID[0] {
return 0, nil, fmt.Errorf("gamedata: invalid promotion %d -> %d", charID, next[0])
}
types, _ := packedInts(growth, 4)
ids, _ := packedInts(growth, 3)
counts, _ := packedInts(growth, 2)
if len(types) == 0 || len(types) != len(ids) || len(types) != len(counts) {
return 0, nil, fmt.Errorf("gamedata: invalid promotion costs for character %d", charID)
}
costs := make([]PromotionCost, len(types))
for i := range costs {
if counts[i] == 0 || (types[i] != 4 && ids[i] == 0) {
return 0, nil, fmt.Errorf("gamedata: invalid promotion cost for character %d", charID)
}
costs[i] = PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]}
}
return next[0], costs, nil
}
@@ -0,0 +1,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)
}
}
+96
View File
@@ -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
}
+153
View File
@@ -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)
}
}
+363
View File
@@ -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")
}
}
+282
View File
@@ -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)
}
}