1095 lines
37 KiB
Go
1095 lines
37 KiB
Go
package gamedata
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"math/big"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
|
|
_ "modernc.org/sqlite"
|
|
|
|
"bd2server/internal/wire"
|
|
)
|
|
|
|
const (
|
|
InfiniteGachaID = 9100033
|
|
InfiniteProductGroupID = 1100001
|
|
InfiniteProductID = 9100033
|
|
officialRateScale = 10000
|
|
officialFiveStarRate = 300
|
|
officialFourStarRate = 1400
|
|
)
|
|
|
|
// InfiniteGachaDesign is the non-account portion of the 2.34.13 paid
|
|
// "infinite reroll" product. The local server deliberately makes final
|
|
// confirmation free, but still reads the official result pool from GameData.
|
|
type InfiniteGachaDesign struct {
|
|
Count int
|
|
CostumeIDs []uint64 // five-star compatibility view
|
|
FiveStarIDs []uint64
|
|
FourStarIDs []uint64
|
|
ThreeStarIDs []uint64
|
|
characters map[uint64]CharacterDesign
|
|
}
|
|
|
|
type CharacterDesign struct {
|
|
ID uint64
|
|
HP uint64
|
|
CostumeMaxLevel uint64
|
|
OverflowItemType uint64
|
|
OverflowItemID uint64
|
|
OverflowItemCount uint64
|
|
}
|
|
|
|
type WeightedCostume struct {
|
|
ID uint64
|
|
Weight uint64
|
|
Children []WeightedCostume
|
|
}
|
|
|
|
type RegularGacha struct {
|
|
ID uint64
|
|
Count int
|
|
PriceType uint64
|
|
Price uint64
|
|
Pool []WeightedCostume
|
|
FixedCostumeID uint64
|
|
TicketIDs []uint64
|
|
}
|
|
|
|
type GachaGroupDesign struct {
|
|
ID uint64
|
|
FixedID uint64
|
|
PointCount uint64
|
|
PickUpExchangeCost uint64
|
|
PickUpCostumeID uint64
|
|
OneTimeGachaID uint64
|
|
TenTimeGachaID uint64
|
|
SelectCount uint64
|
|
SelectionChoiceRate uint64
|
|
UseSelectionOnlyFixedApply bool
|
|
GachaSubType uint64
|
|
IsSelectedFromPity bool
|
|
}
|
|
|
|
type GachaFixedDesign struct {
|
|
ID uint64
|
|
CostumeGrade4Count uint64
|
|
CostumeGrade5Count uint64
|
|
ResetOnMatchingGrade bool
|
|
}
|
|
|
|
type GachaFixedRoll struct {
|
|
CostumeGrade4Count uint64
|
|
CostumeGrade5Count uint64
|
|
CostumeGrade4Sort int
|
|
CostumeGrade5Sort int
|
|
SelectionSorts []int
|
|
}
|
|
|
|
type RegularGachaCatalog struct {
|
|
Gachas map[uint64]RegularGacha
|
|
characters map[uint64]CharacterDesign
|
|
groups map[uint64]GachaGroupDesign
|
|
byGacha map[uint64]uint64
|
|
fixed map[uint64]GachaFixedDesign
|
|
grades map[uint64]uint64
|
|
}
|
|
|
|
// GachaMigrationFact is the minimal stable GameData view consumed by the
|
|
// cross-language state repair tool. It deliberately excludes roll weights and
|
|
// other online-only catalog fields.
|
|
type GachaMigrationFact struct {
|
|
GachaID, GroupID, FixedID, PointCount uint64
|
|
}
|
|
|
|
type CostumeMigrationFact struct {
|
|
CostumeID, Grade, MaxLevel uint64
|
|
OverflowItemType, OverflowItemID, OverflowCount uint64
|
|
}
|
|
|
|
func (c *RegularGachaCatalog) MigrationFacts() ([]GachaMigrationFact, []CostumeMigrationFact) {
|
|
if c == nil {
|
|
return nil, nil
|
|
}
|
|
gachas := make([]GachaMigrationFact, 0, len(c.byGacha))
|
|
for gachaID, groupID := range c.byGacha {
|
|
group := c.groups[groupID]
|
|
gachas = append(gachas, GachaMigrationFact{GachaID: gachaID, GroupID: group.ID, FixedID: group.FixedID, PointCount: group.PointCount})
|
|
}
|
|
sort.Slice(gachas, func(i, j int) bool { return gachas[i].GachaID < gachas[j].GachaID })
|
|
costumes := make([]CostumeMigrationFact, 0, len(c.characters))
|
|
for costumeID, design := range c.characters {
|
|
costumes = append(costumes, CostumeMigrationFact{CostumeID: costumeID, Grade: c.grades[costumeID], MaxLevel: design.CostumeMaxLevel,
|
|
OverflowItemType: design.OverflowItemType, OverflowItemID: design.OverflowItemID, OverflowCount: design.OverflowItemCount})
|
|
}
|
|
sort.Slice(costumes, func(i, j int) bool { return costumes[i].CostumeID < costumes[j].CostumeID })
|
|
return gachas, costumes
|
|
}
|
|
|
|
func NewRegularGachaCatalog(gachas map[uint64]RegularGacha, characters map[uint64]CharacterDesign) (*RegularGachaCatalog, error) {
|
|
if len(gachas) == 0 || len(characters) == 0 {
|
|
return nil, errors.New("gamedata: invalid regular gacha catalog")
|
|
}
|
|
catalog := &RegularGachaCatalog{
|
|
Gachas: make(map[uint64]RegularGacha, len(gachas)), characters: make(map[uint64]CharacterDesign, len(characters)),
|
|
groups: map[uint64]GachaGroupDesign{}, byGacha: map[uint64]uint64{}, fixed: map[uint64]GachaFixedDesign{}, grades: map[uint64]uint64{},
|
|
}
|
|
for id, character := range characters {
|
|
catalog.characters[id] = character
|
|
}
|
|
for id, gacha := range gachas {
|
|
if id == 0 || gacha.ID != id || gacha.Count <= 0 || (gacha.PriceType != 2 && gacha.PriceType != 3) || gacha.Price == 0 {
|
|
return nil, errors.New("gamedata: invalid regular gacha definition")
|
|
}
|
|
if err := validateCostumePool(gacha.Pool, catalog.characters); err != nil {
|
|
return nil, err
|
|
}
|
|
if gacha.FixedCostumeID != 0 {
|
|
if _, ok := catalog.characters[gacha.FixedCostumeID]; !ok {
|
|
return nil, fmt.Errorf("gamedata: fixed costume %d lacks character design", gacha.FixedCostumeID)
|
|
}
|
|
}
|
|
catalog.Gachas[id] = gacha
|
|
catalog.recordPoolGrades(gacha)
|
|
}
|
|
return catalog, nil
|
|
}
|
|
|
|
// AddGroupDesign supports small, schema-accurate fixtures. Production uses
|
|
// loadGroupsAndFixed to read the same fields from the live GameData tables.
|
|
func (c *RegularGachaCatalog) AddGroupDesign(group GachaGroupDesign, fixed GachaFixedDesign) error {
|
|
if c == nil || group.ID == 0 || group.PointCount == 0 {
|
|
return errors.New("gamedata: invalid gacha group")
|
|
}
|
|
if group.OneTimeGachaID != 0 {
|
|
if _, ok := c.Gachas[group.OneTimeGachaID]; !ok {
|
|
return fmt.Errorf("gamedata: missing one-time gacha %d", group.OneTimeGachaID)
|
|
}
|
|
c.byGacha[group.OneTimeGachaID] = group.ID
|
|
}
|
|
if group.TenTimeGachaID != 0 {
|
|
if _, ok := c.Gachas[group.TenTimeGachaID]; !ok {
|
|
return fmt.Errorf("gamedata: missing ten-time gacha %d", group.TenTimeGachaID)
|
|
}
|
|
c.byGacha[group.TenTimeGachaID] = group.ID
|
|
}
|
|
if group.FixedID != 0 {
|
|
if fixed.ID != group.FixedID || fixed.CostumeGrade4Count == 0 || fixed.CostumeGrade5Count == 0 {
|
|
return errors.New("gamedata: invalid gacha fixed group")
|
|
}
|
|
c.fixed[fixed.ID] = fixed
|
|
}
|
|
c.groups[group.ID] = group
|
|
return nil
|
|
}
|
|
|
|
func (c *RegularGachaCatalog) GroupForGacha(gachaID uint64) (GachaGroupDesign, bool) {
|
|
if c == nil {
|
|
return GachaGroupDesign{}, false
|
|
}
|
|
groupID, ok := c.byGacha[gachaID]
|
|
if !ok {
|
|
return GachaGroupDesign{}, false
|
|
}
|
|
group, ok := c.groups[groupID]
|
|
return group, ok
|
|
}
|
|
|
|
func (c *RegularGachaCatalog) Group(groupID uint64) (GachaGroupDesign, bool) {
|
|
if c == nil {
|
|
return GachaGroupDesign{}, false
|
|
}
|
|
group, ok := c.groups[groupID]
|
|
return group, ok
|
|
}
|
|
|
|
func (c *RegularGachaCatalog) Fixed(fixedID uint64) (GachaFixedDesign, bool) {
|
|
if c == nil {
|
|
return GachaFixedDesign{}, false
|
|
}
|
|
fixed, ok := c.fixed[fixedID]
|
|
return fixed, ok
|
|
}
|
|
|
|
func (c *RegularGachaCatalog) CostumeGrade(costumeID uint64) (uint64, bool) {
|
|
if c == nil {
|
|
return 0, false
|
|
}
|
|
grade, ok := c.grades[costumeID]
|
|
return grade, ok
|
|
}
|
|
|
|
func (c *RegularGachaCatalog) recordPoolGrades(gacha RegularGacha) {
|
|
if gacha.FixedCostumeID != 0 {
|
|
c.grades[gacha.FixedCostumeID] = 5
|
|
}
|
|
for branch, item := range gacha.Pool {
|
|
grade := uint64(0)
|
|
switch len(gacha.Pool) {
|
|
case 4:
|
|
if branch < 2 {
|
|
grade = 5
|
|
} else {
|
|
grade = uint64(6 - branch) // branch 2/3 => grade 4/3
|
|
}
|
|
case 3:
|
|
grade = uint64(5 - branch)
|
|
}
|
|
if grade != 0 {
|
|
recordCostumeGrade(c.grades, item, grade)
|
|
}
|
|
}
|
|
}
|
|
|
|
func recordCostumeGrade(grades map[uint64]uint64, item WeightedCostume, grade uint64) {
|
|
if item.ID != 0 {
|
|
if current := grades[item.ID]; current == 0 || grade > current {
|
|
grades[item.ID] = grade
|
|
}
|
|
return
|
|
}
|
|
for _, child := range item.Children {
|
|
recordCostumeGrade(grades, child, grade)
|
|
}
|
|
}
|
|
|
|
func (c *RegularGachaCatalog) Character(costumeID uint64) (CharacterDesign, bool) {
|
|
if c == nil {
|
|
return CharacterDesign{}, false
|
|
}
|
|
value, ok := c.characters[costumeID]
|
|
return value, ok
|
|
}
|
|
|
|
func (c *RegularGachaCatalog) Gacha(id uint64) (RegularGacha, bool) {
|
|
if c == nil {
|
|
return RegularGacha{}, false
|
|
}
|
|
value, ok := c.Gachas[id]
|
|
return value, ok
|
|
}
|
|
|
|
func (g RegularGacha) Roll() ([]uint64, error) {
|
|
return g.rollWith(func(limit uint64) (uint64, error) {
|
|
selected, err := rand.Int(rand.Reader, new(big.Int).SetUint64(limit))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return selected.Uint64(), nil
|
|
})
|
|
}
|
|
|
|
// RollWithCostumeFixed applies the resettable GachaFixedTable counters one
|
|
// slot at a time. The ordinary pickup pool layout is pickup-5, other-5,
|
|
// grade-4, grade-3; a guaranteed five-star therefore preserves the official
|
|
// 50/50 pickup split, while a guaranteed four-star uses the real grade-4 pool.
|
|
func (g RegularGacha) RollWithCostumeFixed(previous4, previous5 uint64, fixed GachaFixedDesign) ([]uint64, GachaFixedRoll, error) {
|
|
return g.RollWithCostumeFixedSelection(previous4, previous5, fixed, nil, nil)
|
|
}
|
|
|
|
func (g RegularGacha) RollWithCostumeFixedSelection(previous4, previous5 uint64, fixed GachaFixedDesign, normalSelectedFive, pitySelectedFive []uint64) ([]uint64, GachaFixedRoll, error) {
|
|
return g.rollWithCostumeFixed(previous4, previous5, fixed, normalSelectedFive, pitySelectedFive, func(limit uint64) (uint64, error) {
|
|
selected, err := rand.Int(rand.Reader, new(big.Int).SetUint64(limit))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return selected.Uint64(), nil
|
|
})
|
|
}
|
|
|
|
func (g RegularGacha) rollWithCostumeFixed(previous4, previous5 uint64, fixed GachaFixedDesign, normalSelectedFive, pitySelectedFive []uint64, draw func(uint64) (uint64, error)) ([]uint64, GachaFixedRoll, error) {
|
|
state := GachaFixedRoll{CostumeGrade4Count: previous4, CostumeGrade5Count: previous5, CostumeGrade4Sort: -1, CostumeGrade5Sort: -1}
|
|
if g.Count <= 0 || (len(g.Pool) != 3 && len(g.Pool) != 4) || fixed.ID == 0 || fixed.CostumeGrade4Count == 0 || fixed.CostumeGrade5Count == 0 || draw == nil {
|
|
return nil, state, errors.New("gamedata: invalid costume fixed gacha")
|
|
}
|
|
fiveBranches := 1
|
|
if len(g.Pool) == 4 {
|
|
fiveBranches = 2
|
|
}
|
|
result := make([]uint64, g.Count)
|
|
for i := range result {
|
|
grade := uint64(0)
|
|
force5 := state.CostumeGrade5Count+1 >= fixed.CostumeGrade5Count
|
|
force4 := !force5 && state.CostumeGrade4Count+1 >= fixed.CostumeGrade4Count
|
|
var id uint64
|
|
var err error
|
|
switch {
|
|
case force5:
|
|
if len(pitySelectedFive) != 0 {
|
|
var selected uint64
|
|
selected, err = draw(uint64(len(pitySelectedFive)))
|
|
if err == nil {
|
|
if selected >= uint64(len(pitySelectedFive)) {
|
|
return nil, state, errors.New("gamedata: selected pity source returned out-of-range value")
|
|
}
|
|
id = pitySelectedFive[selected]
|
|
state.SelectionSorts = append(state.SelectionSorts, i)
|
|
}
|
|
} else {
|
|
id, err = rollCostumeChoiceWith(g.Pool[:fiveBranches], draw)
|
|
}
|
|
grade = 5
|
|
state.CostumeGrade5Sort = i
|
|
case force4:
|
|
id, err = rollCostumeChoiceWith([]WeightedCostume{g.Pool[fiveBranches]}, draw)
|
|
grade = 4
|
|
state.CostumeGrade4Sort = i
|
|
default:
|
|
var branch int
|
|
branch, id, err = rollCostumeBranchWith(g.Pool, draw)
|
|
if branch < fiveBranches {
|
|
grade = 5
|
|
if err == nil && len(normalSelectedFive) != 0 {
|
|
var selected uint64
|
|
selected, err = draw(uint64(len(normalSelectedFive)))
|
|
if err == nil {
|
|
if selected >= uint64(len(normalSelectedFive)) {
|
|
return nil, state, errors.New("gamedata: selected normal source returned out-of-range value")
|
|
}
|
|
id = normalSelectedFive[selected]
|
|
state.SelectionSorts = append(state.SelectionSorts, i)
|
|
}
|
|
}
|
|
} else {
|
|
grade = uint64(4 + fiveBranches - branch)
|
|
}
|
|
}
|
|
if err != nil {
|
|
return nil, state, err
|
|
}
|
|
result[i] = id
|
|
switch grade {
|
|
case 5:
|
|
state.CostumeGrade4Count = 0
|
|
state.CostumeGrade5Count = 0
|
|
case 4:
|
|
state.CostumeGrade4Count = 0
|
|
state.CostumeGrade5Count++
|
|
default:
|
|
state.CostumeGrade4Count++
|
|
state.CostumeGrade5Count++
|
|
}
|
|
}
|
|
return result, state, nil
|
|
}
|
|
|
|
func (g RegularGacha) rollWith(draw func(uint64) (uint64, error)) ([]uint64, error) {
|
|
if g.Count <= 0 || len(g.Pool) == 0 {
|
|
return nil, errors.New("gamedata: invalid regular gacha")
|
|
}
|
|
result := make([]uint64, g.Count)
|
|
start := 0
|
|
if g.FixedCostumeID != 0 {
|
|
result[0] = g.FixedCostumeID
|
|
start = 1
|
|
}
|
|
hasFourOrFive := g.FixedCostumeID != 0
|
|
for i := start; i < len(result); i++ {
|
|
branch, id, err := rollCostumeBranchWith(g.Pool, draw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if branch < len(g.Pool)-1 {
|
|
hasFourOrFive = true
|
|
}
|
|
result[i] = id
|
|
}
|
|
// Official costume ten-pulls guarantee grade four or above. The ordinary
|
|
// four-branch pools are ordered as pickup-5, other-5, grade-4, grade-3.
|
|
// If all ten normal rolls landed in grade 3, replace the final slot with
|
|
// an equal-weight draw from the real grade-4 branch.
|
|
if g.FixedCostumeID == 0 && g.Count == 10 && len(g.Pool) == 4 && !hasFourOrFive {
|
|
id, err := rollCostumeChoiceWith([]WeightedCostume{g.Pool[2]}, draw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result[len(result)-1] = id
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func rollCostumeBranch(pool []WeightedCostume) (int, uint64, error) {
|
|
return rollCostumeBranchWith(pool, func(limit uint64) (uint64, error) {
|
|
selected, err := rand.Int(rand.Reader, new(big.Int).SetUint64(limit))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return selected.Uint64(), nil
|
|
})
|
|
}
|
|
|
|
func rollCostumeBranchWith(pool []WeightedCostume, draw func(uint64) (uint64, error)) (int, uint64, error) {
|
|
var total uint64
|
|
for _, item := range pool {
|
|
if item.Weight == 0 || math.MaxUint64-total < item.Weight {
|
|
return 0, 0, errors.New("gamedata: invalid regular gacha weight")
|
|
}
|
|
total += item.Weight
|
|
}
|
|
value, err := draw(total)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
if value >= total {
|
|
return 0, 0, errors.New("gamedata: random source returned out-of-range value")
|
|
}
|
|
for i, item := range pool {
|
|
if value < item.Weight {
|
|
id, err := rollCostumeChoiceWith([]WeightedCostume{item}, draw)
|
|
return i, id, err
|
|
}
|
|
value -= item.Weight
|
|
}
|
|
return 0, 0, errors.New("gamedata: regular gacha branch selection failed")
|
|
}
|
|
|
|
func rollCostumeChoice(pool []WeightedCostume) (uint64, error) {
|
|
return rollCostumeChoiceWith(pool, func(limit uint64) (uint64, error) {
|
|
selected, err := rand.Int(rand.Reader, new(big.Int).SetUint64(limit))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return selected.Uint64(), nil
|
|
})
|
|
}
|
|
|
|
func rollCostumeChoiceWith(pool []WeightedCostume, 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 regular gacha weight")
|
|
}
|
|
if item.ID == 0 && len(item.Children) == 0 {
|
|
return 0, errors.New("gamedata: empty regular gacha choice")
|
|
}
|
|
total += item.Weight
|
|
}
|
|
value, err := draw(total)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if value >= total {
|
|
return 0, errors.New("gamedata: random source returned out-of-range value")
|
|
}
|
|
for _, item := range pool {
|
|
if value < item.Weight {
|
|
if item.ID != 0 {
|
|
return item.ID, nil
|
|
}
|
|
return rollCostumeChoiceWith(item.Children, draw)
|
|
}
|
|
value -= item.Weight
|
|
}
|
|
return 0, errors.New("gamedata: regular gacha selection failed")
|
|
}
|
|
|
|
// LoadRegularCostumeGacha loads only the ordinary costume pools currently
|
|
// exposed by this local server. Equipment pools remain hidden until complete
|
|
// option-instance generation is implemented.
|
|
func LoadRegularCostumeGacha(root, version string) (*RegularGachaCatalog, error) {
|
|
infinite, err := LoadInfiniteGacha(root, version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
plain, err := ReadQuestDatabase(root, version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dir, err := os.MkdirTemp("", "bd2-regular-gacha-")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
path := filepath.Join(dir, "common.db")
|
|
if err := os.WriteFile(path, plain, 0600); 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()
|
|
catalog := &RegularGachaCatalog{
|
|
Gachas: map[uint64]RegularGacha{}, characters: make(map[uint64]CharacterDesign),
|
|
groups: map[uint64]GachaGroupDesign{}, byGacha: map[uint64]uint64{}, fixed: map[uint64]GachaFixedDesign{}, grades: map[uint64]uint64{},
|
|
}
|
|
for id, character := range infinite.characters {
|
|
catalog.characters[id] = character
|
|
}
|
|
for _, id := range []uint64{100, 101, 10100084, 11000084, 10100145, 11000145, 10100072, 11000072, 8100118, 8100119, 8100120, 8100121} {
|
|
var proto []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM GachaTable WHERE id=?", id).Scan(&proto); err != nil {
|
|
return nil, fmt.Errorf("gamedata: regular gacha %d: %w", id, err)
|
|
}
|
|
counts, _ := packedInts(proto, 5)
|
|
groups, _ := packedInts(proto, 7)
|
|
prices, _ := packedInts(proto, 10)
|
|
priceTypes, _ := packedInts(proto, 12)
|
|
ticketIDs, _ := packedInts(proto, 8)
|
|
if len(counts) != 1 || len(groups) != 1 || len(prices) != 1 || len(priceTypes) != 1 || (priceTypes[0] != 2 && priceTypes[0] != 3) {
|
|
return nil, fmt.Errorf("gamedata: malformed regular gacha %d", id)
|
|
}
|
|
poolGroupID := groups[0]
|
|
var fixedCostumeID uint64
|
|
if id == 8100121 {
|
|
var root []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", groups[0]).Scan(&root); err != nil {
|
|
return nil, err
|
|
}
|
|
dropCount, _ := packedInts(root, 1)
|
|
dropType, _ := packedInts(root, 2)
|
|
rootIDs, _ := packedInts(root, 5)
|
|
rootTypes, _ := packedInts(root, 6)
|
|
if len(dropCount) != 1 || dropCount[0] != 10 || len(dropType) != 1 || dropType[0] != 1 || len(rootIDs) != 2 || len(rootTypes) != 2 || rootTypes[0] != 11 || rootTypes[1] != 9 {
|
|
return nil, errors.New("gamedata: malformed fixed pickup ten-pull")
|
|
}
|
|
fixedCostumeID = rootIDs[0]
|
|
poolGroupID = rootIDs[1]
|
|
}
|
|
pool, err := loadCostumeRewardPool(db, poolGroupID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gamedata: regular gacha %d: %w", id, err)
|
|
}
|
|
var costumeIDs []uint64
|
|
collectCostumeIDs(pool, &costumeIDs)
|
|
if fixedCostumeID != 0 {
|
|
costumeIDs = append(costumeIDs, fixedCostumeID)
|
|
}
|
|
for _, costumeID := range costumeIDs {
|
|
if _, ok := catalog.characters[costumeID]; ok {
|
|
continue
|
|
}
|
|
character, err := loadGachaCharacterDesign(db, costumeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
catalog.characters[costumeID] = character
|
|
}
|
|
if err := validateCostumePool(pool, catalog.characters); err != nil {
|
|
return nil, err
|
|
}
|
|
if fixedCostumeID == 0 {
|
|
var rateError error
|
|
if id == 100 || id == 101 {
|
|
rateError = validateRegularCostumeRates(pool)
|
|
} else {
|
|
rateError = validateOfficialPickupRates(pool)
|
|
}
|
|
if rateError != nil {
|
|
return nil, fmt.Errorf("gamedata: regular gacha %d probability: %w", id, rateError)
|
|
}
|
|
} else if err := validateFixedPickupRemainderRates(pool); err != nil {
|
|
return nil, fmt.Errorf("gamedata: regular gacha %d probability: %w", id, err)
|
|
}
|
|
catalog.Gachas[id] = RegularGacha{ID: id, Count: int(counts[0]), PriceType: priceTypes[0], Price: prices[0], Pool: pool, FixedCostumeID: fixedCostumeID, TicketIDs: ticketIDs}
|
|
catalog.recordPoolGrades(catalog.Gachas[id])
|
|
}
|
|
if err := catalog.loadGroupsAndFixed(db); err != nil {
|
|
return nil, err
|
|
}
|
|
return catalog, nil
|
|
}
|
|
|
|
func validateRegularCostumeRates(pool []WeightedCostume) error {
|
|
if len(pool) != 3 {
|
|
return fmt.Errorf("expected three rarity branches, got %d", len(pool))
|
|
}
|
|
want := [...]uint64{officialFiveStarRate, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate}
|
|
for i, item := range pool {
|
|
if item.Weight != want[i] {
|
|
return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i])
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *RegularGachaCatalog) loadGroupsAndFixed(db *sql.DB) error {
|
|
rows, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var id uint64
|
|
var proto []byte
|
|
if err := rows.Scan(&id, &proto); err != nil {
|
|
return err
|
|
}
|
|
readOne := func(field int) uint64 {
|
|
values, _ := packedInts(proto, field)
|
|
if len(values) == 1 {
|
|
return values[0]
|
|
}
|
|
return 0
|
|
}
|
|
group := GachaGroupDesign{
|
|
ID: id, FixedID: readOne(10), PointCount: readOne(27), PickUpExchangeCost: readOne(25),
|
|
PickUpCostumeID: readOne(26), OneTimeGachaID: readOne(24), TenTimeGachaID: readOne(33),
|
|
SelectCount: readOne(29), SelectionChoiceRate: readOne(31), UseSelectionOnlyFixedApply: readOne(36) != 0,
|
|
GachaSubType: readOne(16), IsSelectedFromPity: readOne(21) != 0,
|
|
}
|
|
_, oneLoaded := c.Gachas[group.OneTimeGachaID]
|
|
_, tenLoaded := c.Gachas[group.TenTimeGachaID]
|
|
if !oneLoaded && !tenLoaded {
|
|
continue
|
|
}
|
|
c.groups[id] = group
|
|
if oneLoaded {
|
|
c.byGacha[group.OneTimeGachaID] = id
|
|
}
|
|
if tenLoaded {
|
|
c.byGacha[group.TenTimeGachaID] = id
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
for _, group := range c.groups {
|
|
if group.FixedID == 0 {
|
|
continue
|
|
}
|
|
if _, exists := c.fixed[group.FixedID]; exists {
|
|
continue
|
|
}
|
|
var proto []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM GachaFixedTable WHERE id=?", group.FixedID).Scan(&proto); err != nil {
|
|
return fmt.Errorf("gamedata: gacha fixed %d: %w", group.FixedID, err)
|
|
}
|
|
four, _ := packedInts(proto, 2)
|
|
five, _ := packedInts(proto, 4)
|
|
reset, _ := packedInts(proto, 6)
|
|
if len(four) != 1 || len(five) != 1 || len(reset) != 1 || four[0] == 0 || five[0] == 0 {
|
|
return fmt.Errorf("gamedata: malformed costume gacha fixed %d", group.FixedID)
|
|
}
|
|
c.fixed[group.FixedID] = GachaFixedDesign{ID: group.FixedID, CostumeGrade4Count: four[0], CostumeGrade5Count: five[0], ResetOnMatchingGrade: reset[0] != 0}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateFixedPickupRemainderRates(pool []WeightedCostume) error {
|
|
if len(pool) != 3 {
|
|
return fmt.Errorf("expected three rarity branches, got %d", len(pool))
|
|
}
|
|
want := [...]uint64{officialFiveStarRate, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate}
|
|
for i, item := range pool {
|
|
if item.Weight != want[i] {
|
|
return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i])
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateOfficialPickupRates prevents a GameData/schema regression from
|
|
// silently changing the published costume pickup rates. The two five-star
|
|
// branches are pickup 1.5% plus the ordinary five-star pool 1.5%.
|
|
func validateOfficialPickupRates(pool []WeightedCostume) error {
|
|
if len(pool) != 4 {
|
|
return fmt.Errorf("expected four rarity branches, got %d", len(pool))
|
|
}
|
|
want := [...]uint64{150, 150, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate}
|
|
for i, item := range pool {
|
|
if item.Weight != want[i] {
|
|
return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i])
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func collectCostumeIDs(pool []WeightedCostume, result *[]uint64) {
|
|
for _, item := range pool {
|
|
if item.ID != 0 {
|
|
*result = append(*result, item.ID)
|
|
} else {
|
|
collectCostumeIDs(item.Children, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func loadGachaCharacterDesign(db *sql.DB, costumeID uint64) (CharacterDesign, error) {
|
|
characterID := costumeID / 10
|
|
var character []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", characterID).Scan(&character); err != nil {
|
|
return CharacterDesign{}, fmt.Errorf("gamedata: costume %d character %d: %w", costumeID, characterID, err)
|
|
}
|
|
growthIDs, _ := packedInts(character, 1)
|
|
baseHP, found, err := fixed64Double(character, 11)
|
|
if err != nil || !found || len(growthIDs) != 1 {
|
|
return CharacterDesign{}, fmt.Errorf("gamedata: character %d invalid base design", characterID)
|
|
}
|
|
var growth []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthIDs[0]).Scan(&growth); err != nil {
|
|
return CharacterDesign{}, err
|
|
}
|
|
levelGroups, _ := packedInts(growth, 1)
|
|
if len(levelGroups) != 1 {
|
|
return CharacterDesign{}, fmt.Errorf("gamedata: character %d invalid level group", characterID)
|
|
}
|
|
var level []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM CharLevelTable WHERE GroupId=? AND id=1", levelGroups[0]).Scan(&level); err != nil {
|
|
return CharacterDesign{}, err
|
|
}
|
|
ratio, found, err := fixed64Double(level, 6)
|
|
if err != nil || !found {
|
|
return CharacterDesign{}, fmt.Errorf("gamedata: character %d invalid level health", characterID)
|
|
}
|
|
hp := math.Trunc(baseHP * (1 + ratio))
|
|
if hp <= 0 || hp > math.MaxUint64 {
|
|
return CharacterDesign{}, fmt.Errorf("gamedata: character %d invalid HP", characterID)
|
|
}
|
|
costume, err := loadCostumeDesign(db, costumeID)
|
|
if err != nil {
|
|
return CharacterDesign{}, err
|
|
}
|
|
costume.ID = characterID
|
|
costume.HP = uint64(hp)
|
|
return costume, nil
|
|
}
|
|
|
|
// loadCostumeDesign reads the duplicate-upgrade boundary and the official
|
|
// post-max exchange directly from CostumeTable/CostumeGrowthTable. Mileage has
|
|
// no item ID, so a zero OverflowItemID is valid when OverflowItemType is 20.
|
|
func loadCostumeDesign(db *sql.DB, costumeID uint64) (CharacterDesign, error) {
|
|
var costume []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM CostumeTable WHERE id=?", costumeID).Scan(&costume); err != nil {
|
|
return CharacterDesign{}, fmt.Errorf("gamedata: costume %d: %w", costumeID, err)
|
|
}
|
|
growthGroups, _ := packedInts(costume, 10)
|
|
maxLevels, _ := packedInts(costume, 15)
|
|
if len(growthGroups) != 1 || len(maxLevels) != 1 || maxLevels[0] == 0 {
|
|
return CharacterDesign{}, fmt.Errorf("gamedata: costume %d invalid growth boundary", costumeID)
|
|
}
|
|
var growth []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM CostumeGrowthTable WHERE GroupId=? AND id=?", growthGroups[0], maxLevels[0]).Scan(&growth); err != nil {
|
|
return CharacterDesign{}, fmt.Errorf("gamedata: costume %d max growth: %w", costumeID, err)
|
|
}
|
|
mileageCounts, _ := packedInts(growth, 9)
|
|
mileageIDs, _ := packedInts(growth, 10)
|
|
mileageTypes, _ := packedInts(growth, 11)
|
|
overCounts, _ := packedInts(growth, 12)
|
|
if len(mileageCounts) != 1 || mileageCounts[0] == 0 || len(mileageTypes) != 1 || mileageTypes[0] == 0 || len(overCounts) != 1 || overCounts[0] != maxLevels[0]+1 {
|
|
return CharacterDesign{}, fmt.Errorf("gamedata: costume %d invalid overflow exchange", costumeID)
|
|
}
|
|
var mileageID uint64
|
|
if len(mileageIDs) == 1 {
|
|
mileageID = mileageIDs[0]
|
|
} else if len(mileageIDs) != 0 {
|
|
return CharacterDesign{}, fmt.Errorf("gamedata: costume %d invalid overflow item id", costumeID)
|
|
}
|
|
return CharacterDesign{
|
|
CostumeMaxLevel: maxLevels[0], OverflowItemType: mileageTypes[0],
|
|
OverflowItemID: mileageID, OverflowItemCount: mileageCounts[0],
|
|
}, nil
|
|
}
|
|
|
|
func loadCostumeRewardPool(db *sql.DB, groupID uint64) ([]WeightedCostume, error) {
|
|
var proto []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", groupID).Scan(&proto); err != nil {
|
|
return nil, err
|
|
}
|
|
ids, _ := packedInts(proto, 5)
|
|
types, _ := packedInts(proto, 6)
|
|
weights, _ := packedInts(proto, 8)
|
|
if len(ids) == 0 || len(ids) != len(types) || len(ids) != len(weights) {
|
|
return nil, errors.New("malformed reward group")
|
|
}
|
|
var result []WeightedCostume
|
|
for i, id := range ids {
|
|
switch types[i] {
|
|
case 11:
|
|
result = append(result, WeightedCostume{ID: id, Weight: weights[i]})
|
|
case 9:
|
|
children, err := loadCostumeRewardPool(db, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, WeightedCostume{Weight: weights[i], Children: children})
|
|
default:
|
|
return nil, fmt.Errorf("unsupported costume reward type %d", types[i])
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func validateCostumePool(pool []WeightedCostume, characters map[uint64]CharacterDesign) error {
|
|
if len(pool) == 0 {
|
|
return errors.New("gamedata: empty costume pool")
|
|
}
|
|
for _, item := range pool {
|
|
if item.ID != 0 {
|
|
if _, ok := characters[item.ID]; !ok {
|
|
return fmt.Errorf("gamedata: regular gacha costume %d lacks character design", item.ID)
|
|
}
|
|
continue
|
|
}
|
|
if err := validateCostumePool(item.Children, characters); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func NewInfiniteGachaDesign(count int, costumeIDs []uint64, characters map[uint64]CharacterDesign) (*InfiniteGachaDesign, error) {
|
|
return NewInfiniteGachaDesignWithRates(count, costumeIDs, costumeIDs, costumeIDs, characters)
|
|
}
|
|
|
|
// NewInfiniteGachaDesignWithRates builds the local reroll design. Its first
|
|
// Count-1 slots use the official costume base rates (3%/14%/83%), while the
|
|
// last slot is a five-star guarantee chosen uniformly from the real eligible
|
|
// five-star pool.
|
|
func NewInfiniteGachaDesignWithRates(count int, fiveStarIDs, fourStarIDs, threeStarIDs []uint64, characters map[uint64]CharacterDesign) (*InfiniteGachaDesign, error) {
|
|
if count <= 0 || len(fiveStarIDs) == 0 || len(fourStarIDs) == 0 || len(threeStarIDs) == 0 || len(characters) == 0 {
|
|
return nil, fmt.Errorf("gamedata: invalid infinite gacha design")
|
|
}
|
|
design := &InfiniteGachaDesign{
|
|
Count: count, CostumeIDs: append([]uint64(nil), fiveStarIDs...),
|
|
FiveStarIDs: append([]uint64(nil), fiveStarIDs...), FourStarIDs: append([]uint64(nil), fourStarIDs...),
|
|
ThreeStarIDs: append([]uint64(nil), threeStarIDs...), characters: make(map[uint64]CharacterDesign, len(characters)),
|
|
}
|
|
for _, pool := range [][]uint64{design.FiveStarIDs, design.FourStarIDs, design.ThreeStarIDs} {
|
|
for _, id := range pool {
|
|
character, ok := characters[id]
|
|
if id == 0 || !ok || character.ID == 0 || character.HP == 0 {
|
|
return nil, fmt.Errorf("gamedata: costume %d has no valid character", id)
|
|
}
|
|
design.characters[id] = character
|
|
}
|
|
}
|
|
return design, nil
|
|
}
|
|
|
|
func LoadInfiniteGacha(root, version string) (*InfiniteGachaDesign, error) {
|
|
plain, err := ReadQuestDatabase(root, version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dir, err := os.MkdirTemp("", "bd2-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()
|
|
|
|
var gacha []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM GachaTable WHERE id=?", InfiniteGachaID).Scan(&gacha); err != nil {
|
|
return nil, fmt.Errorf("gamedata: infinite gacha: %w", err)
|
|
}
|
|
count, _ := packedInts(gacha, 5)
|
|
reward, _ := packedInts(gacha, 7)
|
|
priceCount, _ := packedInts(gacha, 10)
|
|
if len(count) != 1 || count[0] != 10 || len(reward) != 1 || reward[0] != InfiniteGachaID || len(priceCount) != 0 {
|
|
return nil, fmt.Errorf("gamedata: unexpected infinite gacha definition count=%v reward=%v price=%v", count, reward, priceCount)
|
|
}
|
|
var cash []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM CashProductTable WHERE id=? AND GroupId=?", InfiniteProductID, InfiniteProductGroupID).Scan(&cash); err != nil {
|
|
return nil, fmt.Errorf("gamedata: infinite product: %w", err)
|
|
}
|
|
productID, _ := packedInts(cash, 6)
|
|
groupID, _ := packedInts(cash, 5)
|
|
if len(productID) != 1 || productID[0] != InfiniteProductID || len(groupID) != 1 || groupID[0] != InfiniteProductGroupID {
|
|
return nil, fmt.Errorf("gamedata: unexpected infinite product identity")
|
|
}
|
|
|
|
// GachaTable.FixedGachaRewardId=9100035 is the official costume pool used
|
|
// by the observed infinite previews. RewardGroup item type 11 is Costume.
|
|
var pool []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=9100035").Scan(&pool); err != nil {
|
|
return nil, fmt.Errorf("gamedata: infinite costume pool: %w", err)
|
|
}
|
|
ids, err := packedInts(pool, 5)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
types, err := packedInts(pool, 6)
|
|
if err != nil || len(ids) == 0 || len(ids) != len(types) {
|
|
return nil, fmt.Errorf("gamedata: malformed infinite costume pool")
|
|
}
|
|
characters := make(map[uint64]CharacterDesign)
|
|
// CharLevelTable.Health is a growth ratio, not an absolute HP value. The
|
|
// client computes level-one HP as trunc(CharTable.HealthValue * (1+ratio)).
|
|
// Keeping the two sources separate also avoids rejecting valid fractional
|
|
// ratios such as group 101's 0.2.
|
|
levelOneHealthRatio := make(map[uint64]float64)
|
|
rows, err := db.Query("SELECT ProtoBuf FROM CharLevelTable")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for rows.Next() {
|
|
var levelProto []byte
|
|
if err := rows.Scan(&levelProto); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
groups, _ := packedInts(levelProto, 5)
|
|
levels, _ := packedInts(levelProto, 7)
|
|
health, healthFound, healthErr := fixed64Double(levelProto, 6)
|
|
if healthErr != nil {
|
|
rows.Close()
|
|
return nil, healthErr
|
|
}
|
|
if len(groups) == 1 && len(levels) == 1 && levels[0] == 1 && healthFound && health >= 0 && !math.IsNaN(health) && !math.IsInf(health, 0) {
|
|
levelOneHealthRatio[groups[0]] = health
|
|
}
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
for i, costumeID := range ids {
|
|
if types[i] != 11 || costumeID == 0 {
|
|
return nil, fmt.Errorf("gamedata: infinite pool entry %d has type %d", costumeID, types[i])
|
|
}
|
|
characterID := costumeID / 10
|
|
var character []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", characterID).Scan(&character); err != nil {
|
|
return nil, fmt.Errorf("gamedata: costume %d character %d: %w", costumeID, characterID, err)
|
|
}
|
|
growthID, _ := packedInts(character, 1)
|
|
if len(growthID) != 1 {
|
|
return nil, fmt.Errorf("gamedata: character %d has invalid growth %v", characterID, growthID)
|
|
}
|
|
baseHP, baseHPFound, err := fixed64Double(character, 11)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gamedata: character %d base HP: %w", characterID, err)
|
|
}
|
|
if !baseHPFound || baseHP <= 0 || math.IsNaN(baseHP) || math.IsInf(baseHP, 0) {
|
|
return nil, fmt.Errorf("gamedata: character %d has invalid base HP %v", characterID, baseHP)
|
|
}
|
|
var growth []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM CharGrowthTable WHERE id=?", growthID[0]).Scan(&growth); err != nil {
|
|
return nil, fmt.Errorf("gamedata: character %d growth %d: %w", characterID, growthID[0], err)
|
|
}
|
|
levelGroup, _ := packedInts(growth, 1)
|
|
if len(levelGroup) != 1 {
|
|
return nil, fmt.Errorf("gamedata: character %d has invalid level group %v", characterID, levelGroup)
|
|
}
|
|
healthRatio, found := levelOneHealthRatio[levelGroup[0]]
|
|
if !found {
|
|
return nil, fmt.Errorf("gamedata: character %d has invalid level group %v", characterID, levelGroup)
|
|
}
|
|
hp := math.Trunc(baseHP * (1 + healthRatio))
|
|
if hp <= 0 || hp > math.MaxUint64 {
|
|
return nil, fmt.Errorf("gamedata: character %d computes invalid level-one HP %v", characterID, hp)
|
|
}
|
|
costume, err := loadCostumeDesign(db, costumeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
costume.ID = characterID
|
|
costume.HP = uint64(hp)
|
|
characters[costumeID] = costume
|
|
}
|
|
fourStarPool, err := loadCostumeRewardPool(db, 70008)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gamedata: infinite four-star pool: %w", err)
|
|
}
|
|
threeStarPool, err := loadCostumeRewardPool(db, 70004)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gamedata: infinite three-star pool: %w", err)
|
|
}
|
|
var fourStarIDs, threeStarIDs []uint64
|
|
collectCostumeIDs(fourStarPool, &fourStarIDs)
|
|
collectCostumeIDs(threeStarPool, &threeStarIDs)
|
|
if len(ids) != 129 || len(fourStarIDs) != 13 || len(threeStarIDs) != 16 {
|
|
return nil, fmt.Errorf("gamedata: unexpected infinite rarity pools five=%d four=%d three=%d", len(ids), len(fourStarIDs), len(threeStarIDs))
|
|
}
|
|
for _, costumeID := range append(append([]uint64(nil), fourStarIDs...), threeStarIDs...) {
|
|
if _, exists := characters[costumeID]; exists {
|
|
continue
|
|
}
|
|
character, err := loadGachaCharacterDesign(db, costumeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
characters[costumeID] = character
|
|
}
|
|
return NewInfiniteGachaDesignWithRates(int(count[0]), ids, fourStarIDs, threeStarIDs, characters)
|
|
}
|
|
|
|
func fixed64Double(proto []byte, number int) (float64, bool, error) {
|
|
var result float64
|
|
found := false
|
|
err := wire.Walk(proto, func(field wire.Field) error {
|
|
if field.Number != number {
|
|
return nil
|
|
}
|
|
if field.Type != 1 || len(field.Value) != 8 || found {
|
|
return fmt.Errorf("gamedata: invalid double field %d", number)
|
|
}
|
|
result = math.Float64frombits(binary.LittleEndian.Uint64(field.Value))
|
|
found = true
|
|
return nil
|
|
})
|
|
return result, found, err
|
|
}
|
|
|
|
func (d *InfiniteGachaDesign) Roll() ([]uint64, error) {
|
|
return d.rollWith(func(limit uint64) (uint64, error) {
|
|
selected, err := rand.Int(rand.Reader, new(big.Int).SetUint64(limit))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return selected.Uint64(), nil
|
|
})
|
|
}
|
|
|
|
func (d *InfiniteGachaDesign) rollWith(draw func(uint64) (uint64, error)) ([]uint64, error) {
|
|
if d == nil || d.Count <= 0 || len(d.FiveStarIDs) == 0 || len(d.FourStarIDs) == 0 || len(d.ThreeStarIDs) == 0 || draw == nil {
|
|
return nil, fmt.Errorf("gamedata: invalid infinite gacha design")
|
|
}
|
|
result := make([]uint64, d.Count)
|
|
for i := 0; i < d.Count-1; i++ {
|
|
rate, err := draw(officialRateScale)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var pool []uint64
|
|
switch {
|
|
case rate < officialFiveStarRate:
|
|
pool = d.FiveStarIDs
|
|
case rate < officialFiveStarRate+officialFourStarRate:
|
|
pool = d.FourStarIDs
|
|
default:
|
|
pool = d.ThreeStarIDs
|
|
}
|
|
selected, err := draw(uint64(len(pool)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if selected >= uint64(len(pool)) {
|
|
return nil, errors.New("gamedata: random source returned out-of-range value")
|
|
}
|
|
result[i] = pool[selected]
|
|
}
|
|
selected, err := draw(uint64(len(d.FiveStarIDs)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if selected >= uint64(len(d.FiveStarIDs)) {
|
|
return nil, errors.New("gamedata: random source returned out-of-range value")
|
|
}
|
|
result[d.Count-1] = d.FiveStarIDs[selected]
|
|
return result, nil
|
|
}
|
|
|
|
func (d *InfiniteGachaDesign) Character(costumeID uint64) (CharacterDesign, bool) {
|
|
if d == nil {
|
|
return CharacterDesign{}, false
|
|
}
|
|
character, ok := d.characters[costumeID]
|
|
return character, ok
|
|
}
|