Files
bd2/go/internal/server/gamedata/stat_design.go
T

349 lines
11 KiB
Go

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)
}
var rule EquipmentStatRule
rule.Default, _, err = fixed64Double(proto, 1)
if err != nil {
return StatContribution{}, err
}
rule.Growth, _, err = fixed64Double(proto, 4)
if err != nil {
return StatContribution{}, err
}
rule.Levels, err = fixed32Floats(proto, 6)
if err != nil {
return StatContribution{}, err
}
for i := range rule.Ranks {
rule.Ranks[i], err = fixed32Floats(proto, 7+i)
if err != nil {
return StatContribution{}, err
}
}
return equipmentStatContribution(rule, option, false)
}
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.Trunc(value*factor) / factor
contribution := StatContribution{Option: id}
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
} 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
}