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

188 lines
5.7 KiB
Go

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
}