Implement hunting AP and progress, event tasks, exchanges, games, stories and actions; connect battle and reward handling. Add CashShop plugin and installation, server purchases and entitlements, recharge rules and plugin version metadata. Distribute binary calendars independently; cache GameData in memory and fix single-connection query deadlocks. Improve automatic login and recovery diagnostics; document configuration and release flows.
1888 lines
63 KiB
Go
1888 lines
63 KiB
Go
package gamedata
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"math/big"
|
|
"sort"
|
|
|
|
_ "modernc.org/sqlite"
|
|
|
|
"bd2server/internal/server/wire"
|
|
)
|
|
|
|
const (
|
|
officialRateScale = 10000
|
|
officialFiveStarRate = 300
|
|
officialFourStarRate = 1400
|
|
)
|
|
|
|
// InfiniteGachaDesign is the non-account portion of the paid
|
|
// "infinite reroll" product. The local server deliberately makes final
|
|
// confirmation free, but still reads the official result pool from GameData.
|
|
type InfiniteGachaDesign struct {
|
|
GachaID, ProductGroupID, ProductID, SaleGroup uint64
|
|
rewardProgram *CostumeRewardGroup
|
|
GroupID uint64
|
|
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 {
|
|
Grades map[uint64]uint64
|
|
ID uint64
|
|
Count int
|
|
DailyPayGachaCount uint64
|
|
DailyPayGachaPriceCount uint64
|
|
FreeCountDay uint64
|
|
PriceType uint64
|
|
PriceID uint64
|
|
Price uint64
|
|
Pool []WeightedCostume
|
|
RewardGroup *CostumeRewardGroup
|
|
FixedCostumeID uint64
|
|
TicketIDs []uint64
|
|
}
|
|
|
|
type GachaGroupDesign struct {
|
|
ID uint64
|
|
GachaType uint64
|
|
BuyLimitCount uint64
|
|
CashProductGroupID uint64
|
|
CashProductID uint64
|
|
CashSalesGroup uint64
|
|
CashRewards []BattleReward
|
|
FixedID uint64
|
|
PointCount uint64
|
|
PickUpExchangeCost uint64
|
|
PickUpCostumeID uint64
|
|
OneTimeGachaID uint64
|
|
TenTimeGachaID uint64
|
|
SelectCount uint64
|
|
SelectionChangeCount uint64
|
|
SelectionChoiceRate uint64
|
|
UseSelectionOnlyFixedApply bool
|
|
GachaSubType uint64
|
|
IsSelectedFromPity bool
|
|
}
|
|
|
|
// CostumeRewardGroup preserves the audited costume reward program.
|
|
// DropType 1 is the current fixed combination mode with ratio=1 per child;
|
|
// this is distinct from field-object independent percentage rolls. Unsupported
|
|
// combination ratios fail loading rather than silently discarding probability.
|
|
// CostumeRewardGroup preserves RewardGroupTable's execution structure. A
|
|
// zero DropType repeats one weighted choice DropCount times; DropType 1 emits
|
|
// every entry once. Keeping that distinction is required for selection and
|
|
// step-up gachas whose root combines several independently-sized child groups.
|
|
type CostumeRewardGroup struct {
|
|
ID uint64
|
|
DropCount uint64
|
|
DropType uint64
|
|
Entries []CostumeRewardEntry
|
|
}
|
|
|
|
type CostumeRewardEntry struct {
|
|
ItemType uint64
|
|
ItemID uint64
|
|
Count uint64
|
|
Weight uint64
|
|
Group *CostumeRewardGroup
|
|
}
|
|
|
|
type GachaStepDesign struct {
|
|
Step uint64
|
|
GroupID uint64
|
|
GachaID uint64
|
|
FixedID uint64
|
|
IsDisplayFixedItem uint64
|
|
}
|
|
|
|
type GachaStepUpDesign struct {
|
|
ID uint64
|
|
Steps []GachaStepDesign
|
|
}
|
|
|
|
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
|
|
gachaEventAddFreeCount uint64
|
|
characters map[uint64]CharacterDesign
|
|
groups map[uint64]GachaGroupDesign
|
|
byGacha map[uint64]uint64
|
|
fixed map[uint64]GachaFixedDesign
|
|
grades map[uint64]uint64
|
|
stepUps map[uint64]GachaStepUpDesign
|
|
stepByGacha map[uint64]GachaStepDesign
|
|
}
|
|
|
|
func (c *RegularGachaCatalog) GachaEventAddFreeCount() uint64 {
|
|
if c == nil {
|
|
return 0
|
|
}
|
|
return c.gachaEventAddFreeCount
|
|
}
|
|
|
|
func (c *RegularGachaCatalog) SetGachaEventAddFreeCount(count uint64) error {
|
|
if c == nil || count > 256 {
|
|
return errors.New("gamedata: invalid gacha event free-count bonus")
|
|
}
|
|
c.gachaEventAddFreeCount = count
|
|
return nil
|
|
}
|
|
|
|
// 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{},
|
|
stepUps: map[uint64]GachaStepUpDesign{}, stepByGacha: map[uint64]GachaStepDesign{},
|
|
}
|
|
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.PriceType != 19) || gacha.Price == 0 {
|
|
return nil, errors.New("gamedata: invalid regular gacha definition")
|
|
}
|
|
if gacha.RewardGroup == nil {
|
|
if err := validateCostumePool(gacha.Pool, catalog.characters); err != nil {
|
|
return nil, err
|
|
}
|
|
} else if count, err := costumeRewardCount(gacha.RewardGroup); err != nil || count != uint64(gacha.Count) {
|
|
return nil, fmt.Errorf("gamedata: invalid regular gacha reward program count=%d want=%d: %w", count, gacha.Count, 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) AddStepUpDesign(stepUp GachaStepUpDesign) error {
|
|
if c == nil || stepUp.ID == 0 || len(stepUp.Steps) == 0 {
|
|
return errors.New("gamedata: invalid gacha step-up")
|
|
}
|
|
if c.stepUps == nil {
|
|
c.stepUps = map[uint64]GachaStepUpDesign{}
|
|
}
|
|
if c.stepByGacha == nil {
|
|
c.stepByGacha = map[uint64]GachaStepDesign{}
|
|
}
|
|
copyValue := GachaStepUpDesign{ID: stepUp.ID, Steps: append([]GachaStepDesign(nil), stepUp.Steps...)}
|
|
for i, step := range copyValue.Steps {
|
|
if step.Step != uint64(i+1) || step.GroupID == 0 || step.GachaID == 0 {
|
|
return errors.New("gamedata: invalid gacha step-up step")
|
|
}
|
|
if _, ok := c.Gachas[step.GachaID]; !ok {
|
|
return fmt.Errorf("gamedata: missing step-up gacha %d", step.GachaID)
|
|
}
|
|
if existing, ok := c.stepByGacha[step.GachaID]; ok && existing != step {
|
|
return fmt.Errorf("gamedata: step-up gacha %d is already registered", step.GachaID)
|
|
}
|
|
}
|
|
c.stepUps[stepUp.ID] = copyValue
|
|
for _, step := range copyValue.Steps {
|
|
c.stepByGacha[step.GachaID] = step
|
|
}
|
|
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) Groups() []GachaGroupDesign {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
out := make([]GachaGroupDesign, 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 *RegularGachaCatalog) StepUp(groupID uint64) (GachaStepUpDesign, bool) {
|
|
if c == nil {
|
|
return GachaStepUpDesign{}, false
|
|
}
|
|
value, ok := c.stepUps[groupID]
|
|
if ok {
|
|
value.Steps = append([]GachaStepDesign(nil), value.Steps...)
|
|
}
|
|
return value, ok
|
|
}
|
|
|
|
func (c *RegularGachaCatalog) StepUps() []GachaStepUpDesign {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
out := make([]GachaStepUpDesign, 0, len(c.stepUps))
|
|
for id := range c.stepUps {
|
|
value, _ := c.StepUp(id)
|
|
out = append(out, value)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
|
return out
|
|
}
|
|
|
|
func (c *RegularGachaCatalog) StepForGacha(gachaID uint64) (GachaStepDesign, bool) {
|
|
if c == nil {
|
|
return GachaStepDesign{}, false
|
|
}
|
|
value, ok := c.stepByGacha[gachaID]
|
|
return value, 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) {
|
|
for id, grade := range gacha.Grades {
|
|
c.grades[id] = 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 (c *RegularGachaCatalog) SpecialSelectionIDs(gachaID uint64) []uint64 {
|
|
gacha, ok := c.Gacha(gachaID)
|
|
if !ok || gacha.RewardGroup == nil || gacha.RewardGroup.DropType != 1 || len(gacha.RewardGroup.Entries) == 0 {
|
|
return nil
|
|
}
|
|
choice := gacha.RewardGroup.Entries[0].Group
|
|
if choice == nil || choice.DropType != 0 || choice.DropCount == 0 {
|
|
return nil
|
|
}
|
|
result := make([]uint64, 0, len(choice.Entries))
|
|
for _, entry := range choice.Entries {
|
|
if entry.Group != nil || entry.ItemType != 11 || entry.ItemID == 0 || entry.Count != 1 || entry.Weight == 0 {
|
|
return nil
|
|
}
|
|
result = append(result, entry.ItemID)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// FiveStarIDs returns the selectable five-star costumes in one ordinary
|
|
// gacha's authoritative reward pool. Unlike SpecialSelectionIDs it also
|
|
// supports a normal rarity tree, which is used by the newbie guaranteed draw.
|
|
func (c *RegularGachaCatalog) FiveStarIDs(gachaID uint64) []uint64 {
|
|
gacha, ok := c.Gacha(gachaID)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
seen := map[uint64]bool{}
|
|
var result []uint64
|
|
var visit func([]WeightedCostume)
|
|
visit = func(pool []WeightedCostume) {
|
|
for _, entry := range pool {
|
|
if entry.ID == 0 {
|
|
visit(entry.Children)
|
|
continue
|
|
}
|
|
if c.grades[entry.ID] == 5 && !seen[entry.ID] {
|
|
seen[entry.ID] = true
|
|
result = append(result, entry.ID)
|
|
}
|
|
}
|
|
}
|
|
visit(gacha.Pool)
|
|
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
|
|
return result
|
|
}
|
|
|
|
func (c *RegularGachaCatalog) SpecialSelectionCount(gachaID uint64) uint64 {
|
|
gacha, ok := c.Gacha(gachaID)
|
|
if !ok || gacha.RewardGroup == nil || gacha.RewardGroup.DropType != 1 || len(gacha.RewardGroup.Entries) == 0 || gacha.RewardGroup.Entries[0].Group == nil {
|
|
return 0
|
|
}
|
|
return gacha.RewardGroup.Entries[0].Group.DropCount
|
|
}
|
|
|
|
func (g RegularGacha) Roll() ([]uint64, error) {
|
|
if g.RewardGroup != nil {
|
|
return g.rollRewardGroup(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
|
|
})
|
|
}
|
|
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
|
|
})
|
|
}
|
|
|
|
// RollSpecialSelection executes a composite selection-gacha program while
|
|
// replacing its leading five-star pool with the player's saved choices. The
|
|
// current Moonrise design is intentionally validated by structure rather than
|
|
// table IDs: a sequential root first emits the selected rewards and then
|
|
// executes the remaining GameData reward branches unchanged.
|
|
func (g RegularGacha) RollSpecialSelection(selected []uint64) ([]uint64, error) {
|
|
return g.rollSpecialSelection(selected, func(limit uint64) (uint64, error) {
|
|
value, err := rand.Int(rand.Reader, new(big.Int).SetUint64(limit))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return value.Uint64(), nil
|
|
})
|
|
}
|
|
|
|
func (g RegularGacha) rollSpecialSelection(selected []uint64, draw func(uint64) (uint64, error)) ([]uint64, error) {
|
|
root := g.RewardGroup
|
|
if g.Count <= 0 || root == nil || root.DropType != 1 || len(root.Entries) < 2 || draw == nil || len(selected) == 0 {
|
|
return nil, errors.New("gamedata: invalid special selection gacha")
|
|
}
|
|
choice := root.Entries[0].Group
|
|
if choice == nil || choice.DropType != 0 || choice.DropCount == 0 {
|
|
return nil, errors.New("gamedata: special selection reward has no weighted leading pool")
|
|
}
|
|
eligible := make(map[uint64]bool, len(choice.Entries))
|
|
for _, entry := range choice.Entries {
|
|
if entry.Group != nil || entry.ItemType != 11 || entry.ItemID == 0 || entry.Count != 1 || entry.Weight == 0 {
|
|
return nil, errors.New("gamedata: malformed special selection pool")
|
|
}
|
|
eligible[entry.ItemID] = true
|
|
}
|
|
seen := make(map[uint64]bool, len(selected))
|
|
for _, id := range selected {
|
|
if id == 0 || seen[id] || !eligible[id] {
|
|
return nil, fmt.Errorf("gamedata: invalid special selection costume %d", id)
|
|
}
|
|
seen[id] = true
|
|
}
|
|
result := make([]uint64, 0, g.Count)
|
|
for i := uint64(0); i < choice.DropCount; i++ {
|
|
index, err := draw(uint64(len(selected)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if index >= uint64(len(selected)) {
|
|
return nil, errors.New("gamedata: special selection random source returned out-of-range value")
|
|
}
|
|
result = append(result, selected[index])
|
|
}
|
|
for _, entry := range root.Entries[1:] {
|
|
if entry.Group == nil {
|
|
if entry.ItemType != 11 || entry.ItemID == 0 || entry.Count == 0 {
|
|
return nil, errors.New("gamedata: malformed special selection remainder")
|
|
}
|
|
for i := uint64(0); i < entry.Count; i++ {
|
|
result = append(result, entry.ItemID)
|
|
}
|
|
continue
|
|
}
|
|
items, err := rollCostumeRewardGroup(entry.Group, draw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, items...)
|
|
}
|
|
if len(result) != g.Count {
|
|
return nil, fmt.Errorf("gamedata: special selection returned %d items, want %d", len(result), g.Count)
|
|
}
|
|
return result, 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) == 0 || fixed.ID == 0 || (fixed.CostumeGrade4Count == 0 && fixed.CostumeGrade5Count == 0) || draw == nil {
|
|
return nil, state, errors.New("gamedata: invalid costume fixed gacha")
|
|
}
|
|
fivePool, err := g.gradePool(5)
|
|
if err != nil {
|
|
return nil, state, err
|
|
}
|
|
fourPool, err := g.gradePool(4)
|
|
if err != nil {
|
|
return nil, state, err
|
|
}
|
|
result := make([]uint64, g.Count)
|
|
for i := range result {
|
|
grade := uint64(0)
|
|
force5 := fixed.CostumeGrade5Count != 0 && state.CostumeGrade5Count+1 >= fixed.CostumeGrade5Count
|
|
force4 := !force5 && fixed.CostumeGrade4Count != 0 && 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(fivePool, draw)
|
|
}
|
|
grade = 5
|
|
state.CostumeGrade5Sort = i
|
|
case force4:
|
|
id, err = rollCostumeChoiceWith(fourPool, draw)
|
|
grade = 4
|
|
state.CostumeGrade4Sort = i
|
|
default:
|
|
_, id, err = rollCostumeBranchWith(g.Pool, draw)
|
|
grade = g.Grades[id]
|
|
if grade < 3 || grade > 5 {
|
|
return nil, state, errors.New("gamedata: missing costume grade")
|
|
}
|
|
if grade == 5 {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
return nil, state, err
|
|
}
|
|
result[i] = id
|
|
if state.CostumeGrade4Count == math.MaxUint64 || state.CostumeGrade5Count == math.MaxUint64 {
|
|
return nil, state, errors.New("gamedata: costume fixed counter overflow")
|
|
}
|
|
state.CostumeGrade4Count++
|
|
state.CostumeGrade5Count++
|
|
if force4 || force5 || fixed.ResetOnMatchingGrade && grade >= 4 {
|
|
state.CostumeGrade4Count = 0
|
|
}
|
|
if force5 || fixed.ResetOnMatchingGrade && grade == 5 {
|
|
state.CostumeGrade5Count = 0
|
|
}
|
|
|
|
}
|
|
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++ {
|
|
_, id, err := rollCostumeBranchWith(g.Pool, draw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if g.Grades[id] >= 4 {
|
|
hasFourOrFive = true
|
|
}
|
|
result[i] = id
|
|
}
|
|
// Ordinary costume ten-pulls guarantee grade four or above. If all
|
|
// natural results are grade three, condition the actual weighted pool on
|
|
// grade four for the last slot, irrespective of branch order or count.
|
|
if g.FixedCostumeID == 0 && g.Count == 10 && len(g.Grades) != 0 && !hasFourOrFive {
|
|
fourPool, err := g.gradePool(4)
|
|
if err != nil || len(fourPool) == 0 {
|
|
return nil, errors.New("gamedata: grade four guarantee pool unavailable")
|
|
}
|
|
id, err := rollCostumeChoiceWith(fourPool, draw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result[len(result)-1] = id
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (g RegularGacha) rollRewardGroup(draw func(uint64) (uint64, error)) ([]uint64, error) {
|
|
if g.Count <= 0 || g.RewardGroup == nil || draw == nil {
|
|
return nil, errors.New("gamedata: invalid costume reward program")
|
|
}
|
|
result, err := rollCostumeRewardGroup(g.RewardGroup, draw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(result) != g.Count {
|
|
return nil, fmt.Errorf("gamedata: costume reward program returned %d items, want %d", len(result), g.Count)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func rollCostumeRewardGroup(group *CostumeRewardGroup, draw func(uint64) (uint64, error)) ([]uint64, error) {
|
|
if group == nil || group.DropCount == 0 || len(group.Entries) == 0 {
|
|
return nil, errors.New("gamedata: malformed costume reward group")
|
|
}
|
|
emit := func(entry CostumeRewardEntry) ([]uint64, error) {
|
|
if entry.Group != nil {
|
|
return rollCostumeRewardGroup(entry.Group, draw)
|
|
}
|
|
if entry.ItemType != 11 || entry.ItemID == 0 || entry.Count == 0 {
|
|
return nil, errors.New("gamedata: malformed direct costume reward")
|
|
}
|
|
out := make([]uint64, entry.Count)
|
|
for i := range out {
|
|
out[i] = entry.ItemID
|
|
}
|
|
return out, nil
|
|
}
|
|
var out []uint64
|
|
switch group.DropType {
|
|
case 0:
|
|
var total uint64
|
|
for _, entry := range group.Entries {
|
|
if entry.Weight == 0 || math.MaxUint64-total < entry.Weight {
|
|
return nil, errors.New("gamedata: invalid costume reward weight")
|
|
}
|
|
total += entry.Weight
|
|
}
|
|
for i := uint64(0); i < group.DropCount; i++ {
|
|
value, err := draw(total)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if value >= total {
|
|
return nil, errors.New("gamedata: random source returned out-of-range value")
|
|
}
|
|
for _, entry := range group.Entries {
|
|
if value < entry.Weight {
|
|
items, err := emit(entry)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, items...)
|
|
break
|
|
}
|
|
value -= entry.Weight
|
|
}
|
|
}
|
|
case 1:
|
|
for _, entry := range group.Entries {
|
|
items, err := emit(entry)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, items...)
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("gamedata: unsupported costume reward drop type %d", group.DropType)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func costumeRewardCount(group *CostumeRewardGroup) (uint64, error) {
|
|
if group == nil || group.DropCount == 0 || len(group.Entries) == 0 {
|
|
return 0, errors.New("malformed costume reward group")
|
|
}
|
|
entryCount := func(entry CostumeRewardEntry) (uint64, error) {
|
|
if entry.Group != nil {
|
|
return costumeRewardCount(entry.Group)
|
|
}
|
|
if entry.ItemType != 11 || entry.ItemID == 0 || entry.Count == 0 {
|
|
return 0, errors.New("malformed direct costume reward")
|
|
}
|
|
return entry.Count, nil
|
|
}
|
|
if group.DropType == 0 {
|
|
var each uint64
|
|
for _, entry := range group.Entries {
|
|
count, err := entryCount(entry)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if each == 0 {
|
|
each = count
|
|
} else if each != count {
|
|
return 0, errors.New("weighted costume reward entries have different cardinality")
|
|
}
|
|
}
|
|
if math.MaxUint64/group.DropCount < each {
|
|
return 0, errors.New("costume reward count overflow")
|
|
}
|
|
return group.DropCount * each, nil
|
|
}
|
|
if group.DropType != 1 {
|
|
return 0, fmt.Errorf("unsupported costume reward drop type %d", group.DropType)
|
|
}
|
|
var total uint64
|
|
for _, entry := range group.Entries {
|
|
count, err := entryCount(entry)
|
|
if err != nil || math.MaxUint64-total < count {
|
|
return 0, errors.New("invalid costume reward count")
|
|
}
|
|
total += count
|
|
}
|
|
return total, 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) {
|
|
if len(pool) == 0 || draw == nil {
|
|
return 0, 0, errors.New("gamedata: empty costume choice")
|
|
}
|
|
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) {
|
|
if len(pool) == 0 || draw == nil {
|
|
return 0, errors.New("gamedata: empty costume choice")
|
|
}
|
|
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")
|
|
}
|
|
|
|
func LoadActiveGacha(root, version string, costumeGroupIDs, equipmentGroupIDs, stepUpGroupIDs []uint64) (*RegularGachaCatalog, *EquipmentGachaCatalog, error) {
|
|
costumes, err := LoadRegularCostumeGachaGroups(root, version, costumeGroupIDs, stepUpGroupIDs)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
equipment, err := LoadEquipmentGachaGroups(root, version, equipmentGroupIDs)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return costumes, equipment, nil
|
|
}
|
|
|
|
func LoadActiveGachaForSchedules(root, version string, scheduleGroupIDs, stepUpGroupIDs []uint64) (*RegularGachaCatalog, *EquipmentGachaCatalog, error) {
|
|
permanent, err := permanentGachaGroupIDs(root, version)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
scheduleGroupIDs = append(append([]uint64(nil), scheduleGroupIDs...), permanent...)
|
|
costume, equipment, err := ClassifyActiveGachaGroups(root, version, scheduleGroupIDs)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return LoadActiveGacha(root, version, costume, equipment, stepUpGroupIDs)
|
|
}
|
|
|
|
// permanentGachaGroupIDs returns static selection and ordinary equipment groups. ScheduleType=0
|
|
// groups are deliberately absent from the dynamic schedule response; the
|
|
// selection or shared pity/point metadata distinguishes these banners from
|
|
// content-ticket banners handled by other flows.
|
|
func permanentGachaGroupIDs(root, version string) ([]uint64, error) {
|
|
db, closeDB, err := openStatDatabase(root, version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer closeDB()
|
|
rows, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable WHERE scheduleType=0 AND gachaType IN (1,2) ORDER BY id")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gamedata: list permanent gacha groups: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var result []uint64
|
|
for rows.Next() {
|
|
var id uint64
|
|
var raw []byte
|
|
if err := rows.Scan(&id, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
subTypes, _ := packedInts(raw, 16)
|
|
selectCounts, _ := packedInts(raw, 29)
|
|
types, _ := packedInts(raw, 17)
|
|
if len(types) == 1 && types[0] == 1 && len(subTypes) == 1 && subTypes[0] == 1 && len(selectCounts) == 1 && selectCounts[0] != 0 {
|
|
result = append(result, id)
|
|
} else if isPermanentEquipmentGachaGroup(raw) {
|
|
result = append(result, id)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func isPermanentEquipmentGachaGroup(raw []byte) bool {
|
|
types, _ := packedInts(raw, 17)
|
|
subTypes, _ := packedInts(raw, 16)
|
|
selectCounts, _ := packedInts(raw, 29)
|
|
fixed, _ := packedInts(raw, 10)
|
|
points, _ := packedInts(raw, 27)
|
|
one, _ := packedInts(raw, 24)
|
|
ten, _ := packedInts(raw, 33)
|
|
// Static content-ticket groups also have one/ten IDs, but have neither
|
|
// shared pity nor draw points. They must not enter the ordinary catalog.
|
|
// LoadEquipmentGachaGroups validates the referenced products and pools.
|
|
return len(types) == 1 && types[0] == 2 && (len(subTypes) == 0 || len(subTypes) == 1 && subTypes[0] == 0) && len(selectCounts) == 0 &&
|
|
len(fixed) == 1 && fixed[0] != 0 && len(points) == 1 && points[0] != 0 &&
|
|
len(one) == 1 && one[0] != 0 && len(ten) == 1 && ten[0] != 0
|
|
}
|
|
|
|
// ClassifyActiveGachaGroups reads static type metadata for groups already
|
|
// selected by a captured schedule. Resemara is owned by LoadInfiniteGacha and
|
|
// is intentionally omitted from both ordinary catalogs.
|
|
func ClassifyActiveGachaGroups(root, version string, groupIDs []uint64) (costume, equipment []uint64, err error) {
|
|
db, release, err := OpenDatabase(root, version, "common")
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer release()
|
|
seen := map[uint64]bool{}
|
|
for _, id := range groupIDs {
|
|
if seen[id] {
|
|
continue
|
|
}
|
|
seen[id] = true
|
|
var raw []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM GachaGroupTable WHERE id=?", id).Scan(&raw); err != nil {
|
|
return nil, nil, fmt.Errorf("gamedata: active gacha group %d: %w", id, err)
|
|
}
|
|
types, _ := packedInts(raw, 17)
|
|
subTypes, _ := packedInts(raw, 16)
|
|
if len(types) != 1 || len(subTypes) > 1 {
|
|
return nil, nil, fmt.Errorf("gamedata: active gacha group %d has malformed type", id)
|
|
}
|
|
subType := uint64(0)
|
|
if len(subTypes) == 1 {
|
|
subType = subTypes[0]
|
|
}
|
|
if types[0] == 1 && subType == 5 {
|
|
continue
|
|
}
|
|
switch types[0] {
|
|
case 1:
|
|
costume = append(costume, id)
|
|
case 2:
|
|
equipment = append(equipment, id)
|
|
default:
|
|
return nil, nil, fmt.Errorf("gamedata: active gacha group %d has unsupported type %d", id, types[0])
|
|
}
|
|
}
|
|
sort.Slice(costume, func(i, j int) bool { return costume[i] < costume[j] })
|
|
sort.Slice(equipment, func(i, j int) bool { return equipment[i] < equipment[j] })
|
|
return costume, equipment, nil
|
|
}
|
|
|
|
func LoadRegularCostumeGachaGroups(root, version string, groupIDs, stepUpGroupIDs []uint64) (*RegularGachaCatalog, error) {
|
|
db, release, err := OpenDatabase(root, version, "common")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer release()
|
|
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{},
|
|
stepUps: map[uint64]GachaStepUpDesign{}, stepByGacha: map[uint64]GachaStepDesign{},
|
|
}
|
|
var defaults []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&defaults); err != nil {
|
|
return nil, fmt.Errorf("gamedata: gacha defaults: %w", err)
|
|
}
|
|
bonusCounts, err := packedInts(defaults, 48)
|
|
if err != nil || len(bonusCounts) > 1 {
|
|
return nil, errors.New("gamedata: malformed GachaEventAddFreeCount")
|
|
}
|
|
if len(bonusCounts) == 1 {
|
|
if err := catalog.SetGachaEventAddFreeCount(bonusCounts[0]); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
gachaIDs := make(map[uint64]struct{})
|
|
for _, groupID := range groupIDs {
|
|
var raw []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM GachaGroupTable WHERE id=?", groupID).Scan(&raw); err != nil {
|
|
return nil, fmt.Errorf("gamedata: costume gacha group %d: %w", groupID, err)
|
|
}
|
|
types, _ := packedInts(raw, 17)
|
|
if len(types) != 1 || types[0] != 1 {
|
|
return nil, fmt.Errorf("gamedata: group %d is not a costume gacha", groupID)
|
|
}
|
|
for _, field := range []int{24, 33} {
|
|
ids, _ := packedInts(raw, field)
|
|
if len(ids) > 1 {
|
|
return nil, fmt.Errorf("gamedata: group %d has malformed gacha field %d", groupID, field)
|
|
}
|
|
if len(ids) == 1 && ids[0] != 0 {
|
|
gachaIDs[ids[0]] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
for _, stepUpID := range stepUpGroupIDs {
|
|
stepUp, err := loadGachaStepUp(db, stepUpID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
catalog.stepUps[stepUpID] = stepUp
|
|
for _, step := range stepUp.Steps {
|
|
if _, exists := catalog.stepByGacha[step.GachaID]; exists {
|
|
return nil, fmt.Errorf("gamedata: step-up gacha %d belongs to multiple groups", step.GachaID)
|
|
}
|
|
catalog.stepByGacha[step.GachaID] = step
|
|
gachaIDs[step.GachaID] = struct{}{}
|
|
}
|
|
}
|
|
orderedIDs := make([]uint64, 0, len(gachaIDs))
|
|
for id := range gachaIDs {
|
|
orderedIDs = append(orderedIDs, id)
|
|
}
|
|
sort.Slice(orderedIDs, func(i, j int) bool { return orderedIDs[i] < orderedIDs[j] })
|
|
for _, id := range orderedIDs {
|
|
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)
|
|
dailyPayCounts, _ := packedInts(proto, 1)
|
|
dailyPayPrices, _ := packedInts(proto, 2)
|
|
freeCounts, _ := packedInts(proto, 4)
|
|
groups, _ := packedInts(proto, 7)
|
|
prices, _ := packedInts(proto, 10)
|
|
priceIDs, _ := packedInts(proto, 11)
|
|
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 && priceTypes[0] != 19) {
|
|
return nil, fmt.Errorf("gamedata: malformed regular gacha %d", id)
|
|
}
|
|
var priceID uint64
|
|
if len(priceIDs) == 1 {
|
|
priceID = priceIDs[0]
|
|
} else if len(priceIDs) != 0 {
|
|
return nil, fmt.Errorf("gamedata: regular gacha %d has malformed price id", id)
|
|
}
|
|
reward, err := loadCostumeRewardGroup(db, groups[0], map[uint64]bool{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gamedata: regular gacha %d: %w", id, err)
|
|
}
|
|
rewardCount, err := costumeRewardCount(reward)
|
|
if err != nil || rewardCount != counts[0] {
|
|
return nil, fmt.Errorf("gamedata: regular gacha %d reward count=%d want=%d: %w", id, rewardCount, counts[0], err)
|
|
}
|
|
pool, _ := costumeRewardPool(reward)
|
|
fixedCostumeID, remainder := fixedCostumeReward(reward)
|
|
if fixedCostumeID != 0 {
|
|
pool = remainder
|
|
}
|
|
var costumeIDs []uint64
|
|
collectCostumeRewardIDs(reward, &costumeIDs)
|
|
for _, costumeID := range costumeIDs {
|
|
if _, ok := catalog.characters[costumeID]; !ok {
|
|
character, err := loadGachaCharacterDesign(db, costumeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
catalog.characters[costumeID] = character
|
|
}
|
|
grade, err := loadCostumeGrade(db, costumeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
catalog.grades[costumeID] = grade
|
|
}
|
|
if len(pool) != 0 {
|
|
if err := validateCostumePool(pool, catalog.characters); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
var program *CostumeRewardGroup
|
|
if reward.DropType != 0 {
|
|
program = reward
|
|
}
|
|
var dailyPayCount, dailyPayPrice, freeCount uint64
|
|
if len(dailyPayCounts) == 1 {
|
|
dailyPayCount = dailyPayCounts[0]
|
|
} else if len(dailyPayCounts) != 0 {
|
|
return nil, fmt.Errorf("gamedata: regular gacha %d has malformed daily pay count", id)
|
|
}
|
|
if len(dailyPayPrices) == 1 {
|
|
dailyPayPrice = dailyPayPrices[0]
|
|
} else if len(dailyPayPrices) != 0 {
|
|
return nil, fmt.Errorf("gamedata: regular gacha %d has malformed daily pay price", id)
|
|
}
|
|
if len(freeCounts) == 1 {
|
|
freeCount = freeCounts[0]
|
|
} else if len(freeCounts) != 0 {
|
|
return nil, fmt.Errorf("gamedata: regular gacha %d has malformed daily free count", id)
|
|
}
|
|
if (dailyPayCount == 0) != (dailyPayPrice == 0) {
|
|
return nil, fmt.Errorf("gamedata: regular gacha %d has incomplete daily paid design", id)
|
|
}
|
|
catalog.Gachas[id] = RegularGacha{Grades: catalog.grades, ID: id, Count: int(counts[0]), DailyPayGachaCount: dailyPayCount, DailyPayGachaPriceCount: dailyPayPrice, FreeCountDay: freeCount, PriceType: priceTypes[0], PriceID: priceID, Price: prices[0], Pool: pool, RewardGroup: program, FixedCostumeID: fixedCostumeID, TicketIDs: ticketIDs}
|
|
catalog.recordPoolGrades(catalog.Gachas[id])
|
|
}
|
|
if err := catalog.loadGroupsAndFixed(db); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := catalog.loadStepFixed(db); err != nil {
|
|
return nil, err
|
|
}
|
|
return catalog, nil
|
|
}
|
|
|
|
func loadGachaStepUp(db *sql.DB, groupID uint64) (GachaStepUpDesign, error) {
|
|
rows, err := db.Query("SELECT id,ProtoBuf FROM GachaStepUpTable WHERE groupId=? ORDER BY id", groupID)
|
|
if err != nil {
|
|
return GachaStepUpDesign{}, err
|
|
}
|
|
defer rows.Close()
|
|
out := GachaStepUpDesign{ID: groupID}
|
|
for rows.Next() {
|
|
var id uint64
|
|
var raw []byte
|
|
if err := rows.Scan(&id, &raw); err != nil {
|
|
return GachaStepUpDesign{}, err
|
|
}
|
|
one := func(field int) uint64 {
|
|
values, _ := packedInts(raw, field)
|
|
if len(values) == 1 {
|
|
return values[0]
|
|
}
|
|
return 0
|
|
}
|
|
step := GachaStepDesign{Step: one(5), GroupID: one(2), GachaID: one(3), FixedID: one(1), IsDisplayFixedItem: one(6)}
|
|
if id != step.Step || step.Step != uint64(len(out.Steps)+1) || step.GroupID == 0 || step.GachaID == 0 {
|
|
return GachaStepUpDesign{}, fmt.Errorf("gamedata: malformed step-up group %d row %d", groupID, id)
|
|
}
|
|
out.Steps = append(out.Steps, step)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return GachaStepUpDesign{}, err
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return GachaStepUpDesign{}, err
|
|
}
|
|
for _, step := range out.Steps {
|
|
var child []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM GachaGroupTable WHERE id=?", step.GroupID).Scan(&child); err != nil {
|
|
return GachaStepUpDesign{}, fmt.Errorf("gamedata: step-up child group %d: %w", step.GroupID, err)
|
|
}
|
|
types, _ := packedInts(child, 17)
|
|
subTypes, _ := packedInts(child, 16)
|
|
tenIDs, _ := packedInts(child, 33)
|
|
if len(types) != 1 || types[0] != 1 || len(subTypes) != 1 || subTypes[0] != 4 || len(tenIDs) != 1 || tenIDs[0] != step.GachaID {
|
|
return GachaStepUpDesign{}, fmt.Errorf("gamedata: step-up group %d child %d does not reference gacha %d", groupID, step.GroupID, step.GachaID)
|
|
}
|
|
}
|
|
if len(out.Steps) == 0 {
|
|
return GachaStepUpDesign{}, fmt.Errorf("gamedata: step-up group %d is empty", groupID)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func loadCostumeRewardGroup(db *sql.DB, id uint64, visiting map[uint64]bool) (*CostumeRewardGroup, error) {
|
|
if visiting[id] {
|
|
return nil, fmt.Errorf("reward group cycle at %d", id)
|
|
}
|
|
visiting[id] = true
|
|
defer delete(visiting, id)
|
|
var raw []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", id).Scan(&raw); err != nil {
|
|
return nil, err
|
|
}
|
|
one := func(field int) (uint64, bool) {
|
|
values, _ := packedInts(raw, field)
|
|
if len(values) == 1 {
|
|
return values[0], true
|
|
}
|
|
return 0, false
|
|
}
|
|
dropType, _ := one(2)
|
|
dropCount, ok := one(1)
|
|
if dropType > 1 || dropType == 0 && (!ok || dropCount == 0) {
|
|
return nil, errors.New("reward group has invalid drop count")
|
|
}
|
|
ids, _ := packedInts(raw, 5)
|
|
types, _ := packedInts(raw, 6)
|
|
counts, _ := packedInts(raw, 4)
|
|
weights, _ := packedInts(raw, 8)
|
|
if len(ids) == 0 || len(ids) != len(types) || len(ids) != len(counts) || len(ids) != len(weights) {
|
|
return nil, errors.New("malformed reward group")
|
|
}
|
|
group := &CostumeRewardGroup{ID: id, DropCount: dropCount, DropType: dropType, Entries: make([]CostumeRewardEntry, len(ids))}
|
|
for i := range ids {
|
|
entry := CostumeRewardEntry{ItemType: types[i], ItemID: ids[i], Count: counts[i], Weight: weights[i]}
|
|
if entry.Count == 0 || entry.Weight == 0 || dropType == 1 && entry.Weight != 1 {
|
|
return nil, errors.New("reward group has zero count or weight")
|
|
}
|
|
switch entry.ItemType {
|
|
case 9:
|
|
child, err := loadCostumeRewardGroup(db, entry.ItemID, visiting)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entry.Group = child
|
|
case 11:
|
|
default:
|
|
return nil, fmt.Errorf("unsupported costume reward type %d", entry.ItemType)
|
|
}
|
|
group.Entries[i] = entry
|
|
}
|
|
return group, nil
|
|
}
|
|
|
|
func costumeRewardPool(group *CostumeRewardGroup) ([]WeightedCostume, bool) {
|
|
if group == nil || group.DropType != 0 {
|
|
return nil, false
|
|
}
|
|
out := make([]WeightedCostume, 0, len(group.Entries))
|
|
for _, entry := range group.Entries {
|
|
item := WeightedCostume{Weight: entry.Weight}
|
|
if entry.Group != nil {
|
|
if entry.Group.DropCount != 1 {
|
|
return nil, false
|
|
}
|
|
children, ok := costumeRewardPool(entry.Group)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
item.Children = children
|
|
} else if entry.ItemType == 11 && entry.Count == 1 {
|
|
item.ID = entry.ItemID
|
|
} else {
|
|
return nil, false
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
func fixedCostumeReward(group *CostumeRewardGroup) (uint64, []WeightedCostume) {
|
|
if group == nil || group.DropType != 1 || len(group.Entries) != 2 {
|
|
return 0, nil
|
|
}
|
|
var fixed uint64
|
|
var remainder *CostumeRewardGroup
|
|
for _, entry := range group.Entries {
|
|
switch {
|
|
case entry.Group == nil && entry.ItemType == 11 && entry.Count == 1:
|
|
fixed = entry.ItemID
|
|
case entry.Group != nil:
|
|
remainder = entry.Group
|
|
default:
|
|
return 0, nil
|
|
}
|
|
}
|
|
pool, ok := costumeRewardPool(remainder)
|
|
if fixed == 0 || !ok {
|
|
return 0, nil
|
|
}
|
|
return fixed, pool
|
|
}
|
|
|
|
func collectCostumeRewardIDs(group *CostumeRewardGroup, out *[]uint64) {
|
|
if group == nil {
|
|
return
|
|
}
|
|
for _, entry := range group.Entries {
|
|
if entry.Group != nil {
|
|
collectCostumeRewardIDs(entry.Group, out)
|
|
} else if entry.ItemType == 11 {
|
|
*out = append(*out, entry.ItemID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func loadCostumeGrade(db *sql.DB, costumeID uint64) (uint64, error) {
|
|
characterID, err := loadCostumeBaseCharacterID(db, costumeID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var raw []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", characterID).Scan(&raw); err != nil {
|
|
return 0, err
|
|
}
|
|
grades, _ := packedInts(raw, 9)
|
|
if len(grades) != 1 || grades[0] < 3 || grades[0] > 5 {
|
|
return 0, fmt.Errorf("gamedata: costume %d has invalid character grade %v", costumeID, grades)
|
|
}
|
|
return grades[0], 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, GachaType: readOne(17), BuyLimitCount: readOne(2), CashProductGroupID: readOne(3), CashProductID: readOne(4), CashSalesGroup: readOne(5), FixedID: readOne(10), PointCount: readOne(27), PickUpExchangeCost: readOne(25),
|
|
PickUpCostumeID: readOne(26), OneTimeGachaID: readOne(24), TenTimeGachaID: readOne(33),
|
|
SelectCount: readOne(29), SelectionChangeCount: readOne(30), 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
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return err
|
|
}
|
|
for id, group := range c.groups {
|
|
if group.CashProductGroupID != 0 && group.CashProductID != 0 {
|
|
group.CashRewards, err = loadGachaCashRewards(db, group)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.groups[id] = group
|
|
}
|
|
if group.FixedID == 0 {
|
|
continue
|
|
}
|
|
if _, exists := c.fixed[group.FixedID]; exists {
|
|
continue
|
|
}
|
|
fixed, err := loadCostumeFixed(db, group.FixedID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.fixed[group.FixedID] = fixed
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func loadCostumeFixed(db *sql.DB, id uint64) (GachaFixedDesign, error) {
|
|
var proto []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM GachaFixedTable WHERE id=?", id).Scan(&proto); err != nil {
|
|
return GachaFixedDesign{}, fmt.Errorf("gamedata: gacha fixed %d: %w", id, err)
|
|
}
|
|
readOptional := func(field int) (uint64, error) {
|
|
values, err := packedInts(proto, field)
|
|
if err != nil || len(values) > 1 {
|
|
return 0, fmt.Errorf("gamedata: malformed costume gacha fixed %d field %d", id, field)
|
|
}
|
|
if len(values) == 1 {
|
|
return values[0], nil
|
|
}
|
|
return 0, nil
|
|
}
|
|
four, err := readOptional(2)
|
|
if err != nil {
|
|
return GachaFixedDesign{}, err
|
|
}
|
|
five, err := readOptional(4)
|
|
if err != nil {
|
|
return GachaFixedDesign{}, err
|
|
}
|
|
reset, err := readOptional(6)
|
|
if err != nil {
|
|
return GachaFixedDesign{}, err
|
|
}
|
|
if four == 0 && five == 0 {
|
|
return GachaFixedDesign{}, fmt.Errorf("gamedata: costume gacha fixed %d has no threshold", id)
|
|
}
|
|
return GachaFixedDesign{ID: id, CostumeGrade4Count: four, CostumeGrade5Count: five, ResetOnMatchingGrade: reset != 0}, nil
|
|
}
|
|
|
|
func (c *RegularGachaCatalog) loadStepFixed(db *sql.DB) error {
|
|
for stepUpID, stepUp := range c.stepUps {
|
|
for _, step := range stepUp.Steps {
|
|
if _, ok := c.Gachas[step.GachaID]; !ok {
|
|
return fmt.Errorf("gamedata: step-up group %d missing gacha %d", stepUpID, step.GachaID)
|
|
}
|
|
group, ok := c.groups[step.GroupID]
|
|
if !ok || group.TenTimeGachaID != step.GachaID || group.GachaSubType != 4 {
|
|
return fmt.Errorf("gamedata: step-up group %d has invalid child group %d", stepUpID, step.GroupID)
|
|
}
|
|
if step.FixedID == 0 {
|
|
continue
|
|
}
|
|
group.FixedID = step.FixedID
|
|
c.groups[step.GroupID] = group
|
|
if _, ok := c.fixed[step.FixedID]; !ok {
|
|
fixed, err := loadCostumeFixed(db, step.FixedID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.fixed[step.FixedID] = fixed
|
|
}
|
|
}
|
|
}
|
|
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, err := loadCostumeBaseCharacterID(db, costumeID)
|
|
if err != nil {
|
|
return CharacterDesign{}, err
|
|
}
|
|
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 a synthetic reroll policy for tests.
|
|
// Its first Count-1 slots use an explicit 3%/14%/83% synthetic policy, 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 LoadInfiniteGachaForSchedules(root, version string, groups []uint64) (*InfiniteGachaDesign, error) {
|
|
db, release, err := OpenDatabase(root, version, "common")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer release()
|
|
|
|
infiniteGroupID, infiniteGachaID, err := loadInfiniteGachaIdentity(db, groups)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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] == 0 || len(reward) != 1 || reward[0] == 0 || len(priceCount) != 0 {
|
|
return nil, fmt.Errorf("gamedata: unexpected infinite gacha definition count=%v reward=%v price=%v", count, reward, priceCount)
|
|
}
|
|
productGroupID, productID, saleGroup, err := loadInfiniteCashIdentity(db, reward[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
program, err := loadCostumeRewardGroup(db, reward[0], map[uint64]bool{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
programCount, err := costumeRewardCount(program)
|
|
if err != nil || programCount != count[0] {
|
|
return nil, fmt.Errorf("gamedata: infinite reward count does not match gacha")
|
|
}
|
|
|
|
// GachaTable.FixedGachaRewardId=9100035 is the official costume pool used
|
|
// by the observed infinite previews. RewardGroup item type 11 is Costume.
|
|
fixedPool, err := optionalScalar(gacha, 3)
|
|
if err != nil || fixedPool == 0 {
|
|
return nil, errors.New("gamedata: infinite fixed reward missing")
|
|
}
|
|
var pool []byte
|
|
if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", fixedPool).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, err := loadCostumeBaseCharacterID(db, costumeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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
|
|
}
|
|
var allIDs []uint64
|
|
collectCostumeRewardIDs(program, &allIDs)
|
|
var fourStarIDs, threeStarIDs []uint64
|
|
seen := map[uint64]bool{}
|
|
for _, costumeID := range allIDs {
|
|
if seen[costumeID] {
|
|
continue
|
|
}
|
|
seen[costumeID] = true
|
|
grade, err := loadCostumeGrade(db, costumeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch grade {
|
|
case 4:
|
|
fourStarIDs = append(fourStarIDs, costumeID)
|
|
case 3:
|
|
threeStarIDs = append(threeStarIDs, costumeID)
|
|
case 5:
|
|
default:
|
|
return nil, fmt.Errorf("gamedata: unsupported infinite costume grade %d", grade)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
design := &InfiniteGachaDesign{Count: int(count[0]), CostumeIDs: append([]uint64(nil), ids...), FiveStarIDs: ids, FourStarIDs: fourStarIDs, ThreeStarIDs: threeStarIDs, characters: characters}
|
|
design.GroupID, design.GachaID, design.ProductGroupID, design.ProductID, design.SaleGroup = infiniteGroupID, infiniteGachaID, productGroupID, productID, saleGroup
|
|
design.rewardProgram = program
|
|
return design, nil
|
|
}
|
|
|
|
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 || draw == nil {
|
|
return nil, fmt.Errorf("gamedata: invalid infinite gacha design")
|
|
}
|
|
if d.rewardProgram != nil {
|
|
return rollCostumeRewardGroup(d.rewardProgram, draw)
|
|
}
|
|
if len(d.FiveStarIDs) == 0 || len(d.FourStarIDs) == 0 || len(d.ThreeStarIDs) == 0 {
|
|
return nil, errors.New("gamedata: incomplete synthetic infinite pools")
|
|
}
|
|
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
|
|
}
|