Files
bd2/go/internal/server/gamedata/gacha.go
T
Flechazo d8771caef2 feat(all): add hunting and events, commerce and rewards, binary calendars, GameData caching and login diagnostics
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.
2026-10-05 11:50:31 +08:00

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
}