Files
bd2/go/internal/gamedata/stat_design.go
T
2026-09-21 09:53:46 +08:00

208 lines
6.4 KiB
Go

package gamedata
import (
"database/sql"
"encoding/binary"
"fmt"
"math"
"os"
"path/filepath"
"bd2server/internal/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
}
// 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 BaseStats{Health: math.Trunc(baseHP * (1 + hpRatio)), Attack: math.Trunc(baseAttack * (1 + attackRatio)), Magic: math.Trunc(baseMagic * (1 + 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
}