Files
bd2/go/internal/server/gacha/gacha.go
T
Flechazo 5b5a219dc2 feat(server): add special loot and data-driven gameplay rules
Generate equipment loot atomically, grant random boxes and prevent repeat claims.
Read gacha pools, pity, products, tickets, missions, presets, chapter rewards
and inventory costs from GameData; extend both collaboration UR ticket pools.
Sync owned recipes, configure startup state and calendars in versioned seeds,
and validate unlock tickets and revival talents.
2026-10-04 19:15:59 +08:00

1377 lines
50 KiB
Go

// Package gacha implements server-authoritative draws from verified GameData,
// including the starter reroll and scheduled daily-free batches.
package gacha
import (
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"sync"
"time"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/wire"
)
func (s *Service) infiniteGrant() string {
return fmt.Sprintf("cash-product:%d:%d", s.design.ProductGroupID, s.design.ProductID)
}
type Service struct {
design *gamedata.InfiniteGachaDesign
first *gamedata.FirstGachaDesign
regular *gamedata.RegularGachaCatalog
collection *player.CollectionStore
wallet *player.Wallet
inventory *player.Inventory
equipmentCatalog *gamedata.EquipmentGachaCatalog
equipmentInventory *player.EquipmentInventory
onPreview func() error
onDraw func(uint64) error
schedule *ScheduleSeed
previewEventIndex uint64
sessionMu sync.RWMutex
sessionID string
firstMu sync.Mutex
firstPreviews map[string]firstGachaPreview
now func() time.Time
}
func (s *Service) AttachDrawMission(callback func(uint64) error) { s.onDraw = callback }
func (s *Service) AttachPreviewMission(callback func() error) { s.onPreview = callback }
func (s *Service) AttachInventory(inventory *player.Inventory) { s.inventory = inventory }
func (s *Service) AttachPreviewEventIndex(eventIndex uint64) error {
if eventIndex == 0 {
return errors.New("gacha: invalid preview event index")
}
s.previewEventIndex = eventIndex
return nil
}
func (s *Service) AttachSchedule(seed *ScheduleSeed) error {
if err := seed.Validate(seedVersion(seed)); err != nil {
return err
}
copy := *seed
copy.Schedules = append([]ScheduleWindow(nil), seed.Schedules...)
copy.StepUps = append([]ScheduleWindow(nil), seed.StepUps...)
s.schedule = &copy
return nil
}
func (s *Service) AttachEquipmentGacha(catalog *gamedata.EquipmentGachaCatalog, inventory *player.EquipmentInventory) {
s.equipmentCatalog, s.equipmentInventory = catalog, inventory
}
func (s *Service) AttachFirstGacha(design *gamedata.FirstGachaDesign) error {
if design == nil || design.GachaID == 0 || design.Count == 0 || design.Group.GachaSubType != 3 {
return errors.New("gacha: invalid first gacha design")
}
s.first = design
s.firstMu.Lock()
s.firstPreviews = make(map[string]firstGachaPreview)
s.firstMu.Unlock()
return nil
}
// FirstGachaCompleted reflects UserDBInfo.IsFirstGacha. The official symbol
// map names the client-side property IsDoneFirstGachaPick, and the client sets
// it after a GachaSubType=3 purchase succeeds.
func (s *Service) FirstGachaCompleted() bool {
return s.collection.FirstGachaCompleted()
}
// BeginSession activates the transport's current login. The transport calls
// it before every authenticated request, not only when the login is created.
// Request sequence numbers restart with the client, so the login ID remains
// part of durable idempotency keys. In-memory first-gacha previews are already
// keyed by that ID and must survive activation: preview and confirmation are
// separate requests, and clearing here would discard the result immediately
// before the client confirms it.
func (s *Service) BeginSession(id string) {
s.sessionMu.Lock()
s.sessionID = id
s.sessionMu.Unlock()
}
func NewService(design *gamedata.InfiniteGachaDesign, regular *gamedata.RegularGachaCatalog, collection *player.CollectionStore, wallet *player.Wallet) (*Service, error) {
if design == nil || regular == nil || collection == nil || wallet == nil {
return nil, errors.New("gacha: invalid service configuration")
}
return &Service{design: design, regular: regular, collection: collection, wallet: wallet, now: time.Now}, nil
}
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
if path != "/GachaInfo" && path != "/GachaBuy" && path != "/GachaMultiBuy" && path != "/GachaPointExchange" && path != "/GachaPointManualExchange" && path != "/GachaBuyPreview" && path != "/GachaBuyPreviewLock" && path != "/GachaSelectionSave" && path != "/CashShopPurchaseCountInfo" && path != "/CashShopBuy" {
return 0, nil, false, nil
}
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 {
return 0, nil, true, fmt.Errorf("gacha: %s invalid sequence", path)
}
switch path {
case "/GachaInfo":
return 145, s.gachaInfo(), true, nil
case "/GachaBuyPreview":
return s.preview(request)
case "/GachaBuyPreviewLock":
return s.lockPreview(request)
case "/GachaBuy":
return s.buy(request, seq)
case "/GachaMultiBuy":
return s.multiBuy(request, seq)
case "/GachaPointExchange":
return s.pointExchange(request, seq)
case "/GachaPointManualExchange":
return s.manualPointExchange(request, seq)
case "/GachaSelectionSave":
return s.saveSelection(request)
case "/CashShopPurchaseCountInfo":
var response []byte
for _, count := range s.PurchaseCountDBInfos() {
response = wire.AppendBytes(response, 1, count)
}
return 432, response, true, nil
case "/CashShopBuy":
return s.confirm(request)
default:
return 0, nil, false, nil
}
}
// PurchaseCountDBInfos returns the same persisted cash-product purchase state
// used by /CashShopPurchaseCountInfo. LoginUser consumes this method too, so
// both protocol surfaces remain consistent after a restart.
func (s *Service) PurchaseCountDBInfos() [][]byte {
products := []struct {
group, id uint64
bought bool
}{}
_, infiniteBought := s.collection.Grant(s.infiniteGrant())
products = append(products, struct {
group, id uint64
bought bool
}{s.design.ProductGroupID, s.design.ProductID, infiniteBought})
for _, group := range s.regular.Groups() {
if group.CashProductGroupID == 0 || group.CashProductID == 0 {
continue
}
_, bought := s.collection.Grant(fmt.Sprintf("cash-product:%d:%d", group.CashProductGroupID, group.CashProductID))
products = append(products, struct {
group, id uint64
bought bool
}{group.CashProductGroupID, group.CashProductID, bought})
}
var result [][]byte
for _, product := range products {
if !product.bought {
continue
}
count := wire.AppendVarint(nil, 1, product.group)
count = wire.AppendVarint(count, 2, product.id)
count = wire.AppendVarint(count, 4, 1)
result = append(result, count)
}
return result
}
func (s *Service) saveSelection(request []byte) (int, []byte, bool, error) {
var selections []player.GachaSelection
if err := wire.Walk(request, func(field wire.Field) error {
if field.Number != 2 {
return nil
}
if field.Type != 2 {
return errors.New("gacha: invalid selection entry")
}
groupID, groupFound, err := wire.Varint(field.Value, 1)
if err != nil || !groupFound {
return errors.New("gacha: selection group missing")
}
slot, _, err := wire.Varint(field.Value, 2) // slot zero is omitted by protobuf
if err != nil {
return err
}
itemID, itemFound, err := wire.Varint(field.Value, 3)
if err != nil || !itemFound {
return errors.New("gacha: selection item missing")
}
selections = append(selections, player.GachaSelection{GroupID: groupID, Slot: slot, ItemID: itemID})
return nil
}); err != nil {
return 198, nil, true, err
}
if len(selections) == 0 {
return 198, nil, true, errors.New("gacha: empty selection")
}
groupID := selections[0].GroupID
expected := 0
changeLimit := uint64(0)
if group, ok := s.regular.Group(groupID); ok && group.SelectCount != 0 {
expected = int(group.SelectCount)
changeLimit = group.SelectionChangeCount
}
if expected == 0 || len(selections) != expected {
return 198, nil, true, fmt.Errorf("gacha: group %d requires %d selections, got %d", groupID, expected, len(selections))
}
eligible := s.design.FiveStarIDs
if group, ok := s.regular.Group(groupID); ok && group.GachaSubType == 1 && group.TenTimeGachaID != 0 {
if special := s.regular.SpecialSelectionIDs(group.TenTimeGachaID); len(special) != 0 {
eligible = special
} else if fiveStars := s.regular.FiveStarIDs(group.TenTimeGachaID); group.UseSelectionOnlyFixedApply && len(fiveStars) != 0 {
eligible = fiveStars
}
}
validItems := make(map[uint64]bool, len(eligible))
for _, id := range eligible {
validItems[id] = true
}
seenSlots := make(map[uint64]bool, len(selections))
seenItems := make(map[uint64]bool, len(selections))
for _, selection := range selections {
if selection.GroupID != groupID || selection.Slot >= uint64(expected) || seenSlots[selection.Slot] || seenItems[selection.ItemID] || !validItems[selection.ItemID] {
return 198, nil, true, fmt.Errorf("gacha: invalid 12PICK selection group=%d slot=%d item=%d", selection.GroupID, selection.Slot, selection.ItemID)
}
seenSlots[selection.Slot] = true
seenItems[selection.ItemID] = true
}
if err := s.collection.SaveGachaSelections(groupID, selections, changeLimit); err != nil {
return 198, nil, true, err
}
return 198, nil, true, nil
}
func (s *Service) lockPreview(request []byte) (int, []byte, bool, error) {
eventIndex, found, err := wire.Varint(request, 2)
if err != nil || !found || eventIndex == 0 {
return 175, nil, true, errors.New("gacha: missing preview event index")
}
if err := s.collection.LockPreview(eventIndex); err != nil {
return 175, nil, true, err
}
// no separate PacketCodeTypeProto member for the lock RPC.
// It is a sub-operation of GachaBuyPreview and the client callback only
// parses the empty response, so it uses the parent packet code.
return 175, nil, true, nil
}
func (s *Service) multiBuy(request []byte, seq uint64) (int, []byte, bool, error) {
const maxBatchSize = 256
var ids []uint64
appendID := func(id uint64) error {
if id == 0 || id > 1<<31-1 || len(ids) >= maxBatchSize {
return errors.New("gacha: invalid daily free batch ID or size")
}
ids = append(ids, id)
return nil
}
if err := wire.Walk(request, func(field wire.Field) error {
if field.Number != 2 {
return nil
}
switch field.Type {
case 0:
id, n := binary.Uvarint(field.Value)
if n <= 0 {
return wire.ErrMalformed
}
return appendID(id)
case 2:
for packed := field.Value; len(packed) != 0; {
id, n := binary.Uvarint(packed)
if n <= 0 {
return wire.ErrMalformed
}
if err := appendID(id); err != nil {
return err
}
packed = packed[n:]
}
return nil
default:
return errors.New("gacha: invalid daily free batch ID wire type")
}
}); err != nil {
return 146, nil, true, err
}
if len(ids) == 0 {
return 146, nil, true, errors.New("gacha: empty daily free batch")
}
identity := fmt.Sprintf("multi-gacha:session:%s:seq:%d", s.loginIdentity(), seq)
var canonical []byte
for _, id := range ids {
canonical = binary.AppendUvarint(canonical, id)
}
digest := fmt.Sprintf("%x", sha256.Sum256(canonical))
if response, found, err := s.collection.GachaBatchResponse(identity, digest); found || err != nil {
return 146, response, true, err
}
// Validate the complete batch before a single allowance or reward changes.
// Counts are accumulated per group so repeated IDs cannot evade the limit.
day := dailyResetKey(s.now())
requested := make(map[uint64]uint64)
for _, id := range ids {
groupID, allowance := uint64(0), uint64(0)
if design, found := s.regular.Gacha(id); found {
group, grouped := s.regular.GroupForGacha(id)
if grouped && group.OneTimeGachaID == id && design.Count == 1 {
groupID, allowance = group.ID, design.FreeCountDay
}
} else if s.equipmentCatalog != nil {
if design, found := s.equipmentCatalog.Gacha(id); found && !design.TicketOnly && design.Count == 1 {
if s.equipmentInventory == nil {
return 146, nil, true, errors.New("gacha: equipment inventory not attached")
}
if group, grouped := s.equipmentCatalog.GroupForGacha(id); grouped && group.OneTimeGachaID == id {
groupID, allowance = group.ID, design.FreeCountDay
}
}
}
var open bool
var allowanceErr error
allowance, open, allowanceErr = s.dailyFreeAllowance(groupID, allowance)
if allowanceErr != nil {
return 146, nil, true, allowanceErr
}
if allowance == 0 || !open {
return 146, nil, true, fmt.Errorf("gacha: unavailable daily free gacha %d", id)
}
requested[groupID]++
used := s.collection.GachaDailyCount(day, groupID, 0)
if used >= allowance || requested[groupID] > allowance-used {
return 146, nil, true, fmt.Errorf("gacha: daily free batch exceeds allowance group=%d", groupID)
}
}
var response []byte
for slot, id := range ids {
subrequest := wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, id)
slotIdentity := fmt.Sprintf("%s:slot:%d:id:%d", identity, slot, id)
_, single, _, err := s.buyWithIdentity(subrequest, seq, slotIdentity)
if err != nil {
return 146, nil, true, fmt.Errorf("gacha: daily free batch slot %d: %w", slot, err)
}
result := wire.AppendVarint(nil, 1, id)
if err := wire.Walk(single, func(field wire.Field) error {
// Single-buy fields 1..5 correspond to multi-result fields 2..6.
switch field.Type {
case 0:
value, n := binary.Uvarint(field.Value)
if n <= 0 {
return wire.ErrMalformed
}
result = wire.AppendVarint(result, field.Number+1, value)
case 2:
result = wire.AppendBytes(result, field.Number+1, field.Value)
default:
return errors.New("gacha: unexpected single-buy response wire type")
}
return nil
}); err != nil {
return 146, nil, true, err
}
response = wire.AppendBytes(response, 1, result)
}
if err := s.collection.RecordGachaBatch(identity, digest, response); err != nil {
return 146, nil, true, err
}
return 146, response, true, nil
}
func (s *Service) dailyFreeAllowance(groupID, base uint64) (uint64, bool, error) {
if s.schedule == nil {
return 0, false, nil
}
now := s.now().UnixMilli()
for _, entry := range s.schedule.Schedules {
if entry.GroupID != groupID || now < 0 || uint64(now) < entry.StartTime || uint64(now) > entry.EndTime {
continue
}
if entry.FreeCountBonus {
bonus := s.regular.GachaEventAddFreeCount()
if ^uint64(0)-base < bonus {
return 0, false, errors.New("gacha: daily free allowance overflows")
}
base += bonus
}
return base, true, nil
}
return 0, false, nil
}
func (s *Service) buy(request []byte, seq uint64) (int, []byte, bool, error) {
return s.buyWithIdentity(request, seq, "")
}
func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) (int, []byte, bool, error) {
id, found, err := wire.Varint(request, 2)
if err != nil || !found || id == 0 {
return 146, nil, true, errors.New("gacha: missing gacha id")
}
buyType, _, err := wire.Varint(request, 3)
if err != nil || buyType > 3 {
return 146, nil, true, fmt.Errorf("gacha: unsupported buy type %d", buyType)
}
var tickets []player.Item
if err := wire.Walk(request, func(field wire.Field) error {
if field.Number != 4 {
return nil
}
if field.Type != 2 {
return errors.New("gacha: invalid ticket info")
}
var item player.Item
var found bool
var parseErr error
if item.InvenIndex, found, parseErr = wire.Varint(field.Value, 1); parseErr != nil || !found {
return errors.New("gacha: ticket inventory index missing")
}
item.ID, _, parseErr = wire.Varint(field.Value, 2)
if parseErr != nil {
return parseErr
}
item.Type, _, parseErr = wire.Varint(field.Value, 3)
if parseErr != nil {
return parseErr
}
item.Count, _, parseErr = wire.Varint(field.Value, 4)
if parseErr != nil {
return parseErr
}
if item.Type != 8 || item.Count == 0 {
return errors.New("gacha: unsupported ticket")
}
tickets = append(tickets, item)
return nil
}); err != nil {
return 146, nil, true, err
}
if s.first != nil && id == s.first.GachaID {
return s.confirmFirstGacha(request, seq, buyType, tickets, identity)
}
design, ok := s.regular.Gacha(id)
if !ok && s.equipmentCatalog != nil {
if equipment, found := s.equipmentCatalog.Gacha(id); found {
return s.buyEquipmentWithIdentity(seq, buyType, tickets, equipment, identity)
}
}
if !ok {
return 146, nil, true, fmt.Errorf("gacha: unsupported regular gacha id=%d", id)
}
group, knownGroup := s.regular.GroupForGacha(id)
if !knownGroup {
group = gamedata.GachaGroupDesign{ID: id, PointCount: 1}
}
isDailyFree := buyType == 0 && design.Count == 1 && design.FreeCountDay != 0 && knownGroup
var freeDailyLimit uint64
if isDailyFree {
var open bool
freeDailyLimit, open, err = s.dailyFreeAllowance(group.ID, design.FreeCountDay)
if err != nil {
return 146, nil, true, err
}
if !open {
return 146, nil, true, fmt.Errorf("gacha: daily free schedule is closed for group %d", group.ID)
}
}
isDailyPaid := buyType == 2 && design.Count == 1 && design.DailyPayGachaCount != 0 && design.DailyPayGachaPriceCount != 0 && knownGroup
isMoonrise := buyType == 3 && design.PriceType == 19 && design.PriceID != 0 && knownGroup && group.GachaSubType == 1 && group.SelectCount != 0 && group.CashProductID != 0
completionGrant := fmt.Sprintf("special-gacha:%d:%d", group.ID, id)
validOrdinary := buyType == 1 && (design.PriceType == 2 || design.PriceType == 3)
if !isDailyFree && !isDailyPaid && !isMoonrise && !validOrdinary {
priceType := uint64(0)
priceType = design.PriceType
return 146, nil, true, fmt.Errorf("gacha: unsupported regular gacha id=%d buyType=%d priceType=%d (server never substitutes diamonds for another currency)", id, buyType, priceType)
}
if buyType != 1 && len(tickets) != 0 {
return 146, nil, true, errors.New("gacha: discounted or content-ticket draw cannot use ordinary gacha tickets")
}
for _, ticket := range tickets {
allowed := false
for _, ticketID := range design.TicketIDs {
if ticket.ID == ticketID {
allowed = true
break
}
}
if !allowed {
return 146, nil, true, fmt.Errorf("gacha: ticket %d is not valid for gacha %d", ticket.ID, id)
}
}
if identity == "" {
identity = s.requestIdentity(id, seq)
}
grant, already := s.collection.Grant(identity)
if isMoonrise && !already {
if _, completed := s.collection.Grant(completionGrant); completed {
return 146, nil, true, errors.New("gacha: Moonrise product already drawn")
}
}
var moonriseSelected []uint64
if isMoonrise && !already {
selections := s.collection.GachaSelections(group.ID)
if len(selections) != int(group.SelectCount) {
return 146, nil, true, fmt.Errorf("gacha: special selection group %d has %d choices, want %d", group.ID, len(selections), group.SelectCount)
}
moonriseSelected = make([]uint64, len(selections))
for i, selection := range selections {
moonriseSelected[i] = selection.ItemID
}
}
stepUpGroupID, stepDesign, isStepUp := s.stepForGacha(id)
if isStepUp && !already {
completed := s.collection.StepUpProgress(stepUpGroupID)
if completed+1 != stepDesign.Step {
return 146, nil, true, fmt.Errorf("gacha: step-up group %d completed=%d cannot buy step=%d", stepUpGroupID, completed, stepDesign.Step)
}
}
if !already {
if group.BuyLimitCount != 0 && s.collection.GachaUser(group.ID).TotalBuyCount+uint64(design.Count) > group.BuyLimitCount {
return 146, nil, true, fmt.Errorf("gacha: group %d buy limit %d exhausted", group.ID, group.BuyLimitCount)
}
day := dailyResetKey(s.now())
var dailyLimit uint64
if isDailyFree {
dailyLimit = freeDailyLimit
} else if isDailyPaid {
dailyLimit = design.DailyPayGachaCount
}
if dailyLimit != 0 && s.collection.GachaDailyCount(day, group.ID, buyType) >= dailyLimit {
return 146, nil, true, fmt.Errorf("gacha: daily draw exhausted group=%d buyType=%d", group.ID, buyType)
}
var ticketCount uint64
for _, ticket := range tickets {
ticketCount += ticket.Count
}
if ticketCount > uint64(design.Count) || (buyType != 1 && ticketCount != 0) || (design.PriceType != 3 && ticketCount != 0) {
return 146, nil, true, errors.New("gacha: invalid ticket count")
}
var roll []uint64
var fixedStates []player.GachaFixedState
var selectionApplySortIDs []uint64
if isMoonrise {
roll, err = design.RollSpecialSelection(moonriseSelected)
for i := uint64(0); i < s.regular.SpecialSelectionCount(id); i++ {
selectionApplySortIDs = append(selectionApplySortIDs, i)
}
} else if fixed, found := s.regular.Fixed(group.FixedID); found && design.FixedCostumeID == 0 {
var fixedResult gamedata.GachaFixedRoll
var selected, normalSelected, pitySelected []uint64
if group.GachaSubType == 1 {
for _, choice := range s.collection.GachaSelections(group.ID) {
selected = append(selected, choice.ItemID)
}
}
if !group.UseSelectionOnlyFixedApply {
normalSelected = selected
}
if group.IsSelectedFromPity || group.UseSelectionOnlyFixedApply {
pitySelected = selected
}
roll, fixedResult, err = design.RollWithCostumeFixedSelection(s.collection.GachaFixedCount(group.FixedID, 0), s.collection.GachaFixedCount(group.FixedID, 1), fixed, normalSelected, pitySelected)
if err == nil {
fixedStates = costumeFixedStates(fixed, fixedResult)
fixedStates = s.completeSharedFixedStates(fixed.ID, fixedStates)
for _, sortID := range fixedResult.SelectionSorts {
selectionApplySortIDs = append(selectionApplySortIDs, uint64(sortID))
}
}
} else {
roll, err = design.Roll()
}
if err != nil {
return 146, nil, true, err
}
// Generate and validate the complete reward before mutating any cost
// domain. The enclosing session transaction remains the final atomic
// boundary, but deterministic business errors can no longer consume a
// ticket or currency first.
if len(tickets) != 0 {
if s.inventory == nil {
return 146, nil, true, errors.New("gacha: inventory not attached")
}
if err := s.inventory.CanConsume(tickets); err != nil {
return 146, nil, true, err
}
}
var moonriseCost []player.Item
if isMoonrise {
if s.inventory == nil {
return 146, nil, true, errors.New("gacha: inventory not attached")
}
moonriseCost, err = s.inventory.SelectMutable(design.PriceType, design.PriceID, design.Price)
if err != nil {
return 146, nil, true, err
}
}
var charge uint64
var chargePaid bool
switch {
case isDailyPaid:
charge, chargePaid = design.DailyPayGachaPriceCount, true
case !isDailyFree && !isMoonrise:
remaining := uint64(design.Count) - ticketCount
charge = design.Price / uint64(design.Count) * remaining
chargePaid = design.PriceType == 2
}
if charge != 0 {
currency := s.wallet.Snapshot()
if chargePaid && currency.Jewelry < charge {
return 146, nil, true, errors.New("gacha: insufficient paid jewelry")
}
if !chargePaid && currency.FreeJewelry < charge {
return 146, nil, true, errors.New("gacha: insufficient free jewelry")
}
}
if len(tickets) != 0 {
if err := s.inventory.Consume(tickets); err != nil {
return 146, nil, true, err
}
}
switch {
case isDailyFree:
// The daily allowance is the complete cost.
case isDailyPaid:
if _, err := s.wallet.SpendJewelryOnce(identity, charge); err != nil {
return 146, nil, true, err
}
case isMoonrise:
if err := s.inventory.Consume(moonriseCost); err != nil {
return 146, nil, true, err
}
default:
if charge != 0 {
if chargePaid {
if _, err := s.wallet.SpendJewelryOnce(identity, charge); err != nil {
return 146, nil, true, err
}
} else {
if _, err := s.wallet.SpendFreeJewelryOnce(identity, charge); err != nil {
return 146, nil, true, err
}
}
}
}
purchase := player.GachaPurchase{Group: group, BuyType: buyType, Fixed: fixedStates, SelectionApplySortIDs: selectionApplySortIDs}
if isMoonrise {
purchase.CompletionGrant = completionGrant
}
if dailyLimit != 0 {
purchase.DailyKey = day
purchase.DailyLimit = dailyLimit
}
if isStepUp {
purchase.StepUpGroupID = stepUpGroupID
purchase.StepUpStep = stepDesign.Step
}
grant, err = s.collection.GrantRegularPurchase(identity, roll, s.regular, purchase)
if err != nil {
return 146, nil, true, err
}
}
if err := s.creditOverflow(identity, grant); err != nil {
return 146, nil, true, err
}
response := wire.AppendBytes(nil, 1, s.rewardBundle(grant))
response = wire.AppendVarint(response, 2, grant.GachaPoint)
for _, fixed := range grant.GachaFixed {
response = wire.AppendBytes(response, 3, gachaFixedWire(fixed))
}
for _, sortID := range grant.SelectionApplySortIDs {
response = wire.AppendVarint(response, 4, sortID)
}
if !already && s.onDraw != nil {
if err := s.onDraw(uint64(design.Count)); err != nil {
return 146, nil, true, fmt.Errorf("gacha: draw mission: %w", err)
}
}
return 146, response, true, nil
}
func (s *Service) stepForGacha(gachaID uint64) (uint64, gamedata.GachaStepDesign, bool) {
step, ok := s.regular.StepForGacha(gachaID)
if !ok {
return 0, gamedata.GachaStepDesign{}, false
}
for _, group := range s.regular.StepUps() {
for _, candidate := range group.Steps {
if candidate.GachaID == gachaID && candidate == step {
return group.ID, step, true
}
}
}
return 0, gamedata.GachaStepDesign{}, false
}
// Scheduled equipment draws update GachaUser/GachaFixed accounting. Standalone
// ticket-only draws have no schedule group and persist only their idempotency
// marker plus the generated equipment instances.
func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, design gamedata.EquipmentGacha) (int, []byte, bool, error) {
return s.buyEquipmentWithIdentity(seq, buyType, tickets, design, "")
}
func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player.Item, design gamedata.EquipmentGacha, identity string) (int, []byte, bool, error) {
if s.equipmentInventory == nil {
return 146, nil, true, errors.New("gacha: equipment inventory not attached")
}
isDailyFree := buyType == 0 && !design.TicketOnly && design.Count == 1 && design.FreeCountDay != 0
if (buyType != 1 && !isDailyFree) || (!design.TicketOnly && design.PriceType != 3) {
return 146, nil, true, fmt.Errorf("gacha: unsupported equipment buy type %d", buyType)
}
if design.TicketOnly && len(tickets) == 0 {
return 146, nil, true, errors.New("gacha: ticket-only equipment draw requires a ticket")
}
for _, ticket := range tickets {
allowed := false
for _, ticketID := range design.TicketIDs {
if ticket.ID == ticketID {
allowed = true
break
}
}
if !allowed {
return 146, nil, true, fmt.Errorf("gacha: ticket %d is not valid for equipment gacha %d", ticket.ID, design.ID)
}
}
group, hasGroup := s.equipmentCatalog.GroupForGacha(design.ID)
if !hasGroup && !design.TicketOnly {
return 146, nil, true, fmt.Errorf("gacha: equipment gacha %d has no group", design.ID)
}
if isDailyFree && len(tickets) != 0 {
return 146, nil, true, errors.New("gacha: daily free equipment draw cannot use tickets")
}
var freeDailyLimit uint64
if isDailyFree {
var open bool
var err error
freeDailyLimit, open, err = s.dailyFreeAllowance(group.ID, design.FreeCountDay)
if err != nil {
return 146, nil, true, err
}
if !open {
return 146, nil, true, fmt.Errorf("gacha: daily free equipment schedule is closed for group %d", group.ID)
}
}
if identity == "" {
identity = s.requestIdentity(design.ID, seq)
}
grant, already := s.collection.Grant(identity)
var entries []player.Equipment
if already {
for sort := 0; sort < design.Count; sort++ {
entry, found := s.equipmentInventory.Granted(fmt.Sprintf("%s:equip:%d", identity, sort))
if !found {
return 146, nil, true, fmt.Errorf("gacha: equipment retry %s missing slot %d", identity, sort)
}
entries = append(entries, entry)
}
} else {
if isDailyFree && s.collection.GachaDailyCount(dailyResetKey(s.now()), group.ID, 0) >= freeDailyLimit {
return 146, nil, true, fmt.Errorf("gacha: daily equipment draw exhausted group=%d", group.ID)
}
var ticketCount uint64
for _, ticket := range tickets {
ticketCount += ticket.Count
}
if ticketCount > uint64(design.Count) || (design.TicketOnly && ticketCount != uint64(design.Count)) {
return 146, nil, true, errors.New("gacha: invalid equipment ticket count")
}
if ticketCount != 0 {
if s.inventory == nil {
return 146, nil, true, errors.New("gacha: inventory not attached")
}
if err := s.inventory.Consume(tickets); err != nil {
return 146, nil, true, err
}
}
if remain := uint64(design.Count) - ticketCount; remain != 0 && !isDailyFree {
if design.TicketOnly {
return 146, nil, true, errors.New("gacha: ticket-only equipment draw cannot use diamonds")
}
if _, err := s.wallet.SpendFreeJewelryOnce(identity, design.Price/uint64(design.Count)*remain); err != nil {
return 146, nil, true, err
}
}
var fixed gamedata.EquipmentFixedDesign
var priorSR, priorUR uint64
if !design.TicketOnly {
group, found := s.equipmentCatalog.GroupForGacha(design.ID)
if !found {
return 146, nil, true, errors.New("gacha: equipment group missing")
}
fixed, found = s.equipmentCatalog.FixedForGroup(group.ID)
if !found {
return 146, nil, true, errors.New("gacha: equipment fixed design missing")
}
priorSR = s.collection.GachaFixedCount(fixed.ID, 2)
priorUR = s.collection.GachaFixedCount(fixed.ID, 3)
}
roll, state, err := design.Roll(priorSR, priorUR, fixed)
if err != nil {
return 146, nil, true, err
}
var fixedStates []player.GachaFixedState
if !design.TicketOnly {
fixedStates = []player.GachaFixedState{{FixedID: fixed.ID, Type: 2, Count: state.SRCount, ApplySort: state.SRSort}, {FixedID: fixed.ID, Type: 3, Count: state.URCount, ApplySort: state.URSort}}
fixedStates = s.completeSharedFixedStates(fixed.ID, fixedStates)
}
for sort, equipmentID := range roll {
main, sub, private, err := s.equipmentCatalog.RollOptions(equipmentID)
if err != nil {
return 146, nil, true, err
}
entry := player.Equipment{ID: equipmentID, SortID: uint64(sort), Rank: []uint64{0, 0, 0}}
for _, o := range main {
entry.MainOption = append(entry.MainOption, player.EquipmentOption{GroupID: o.GroupID, ID: o.ID})
}
for _, o := range sub {
entry.SubOption = append(entry.SubOption, player.EquipmentOption{GroupID: o.GroupID, ID: o.ID})
}
if private != nil {
entry.PrivateOption = &player.EquipmentOption{GroupID: private.GroupID, ID: private.ID}
}
saved, err := s.equipmentInventory.GrantGeneratedOnce(fmt.Sprintf("%s:equip:%d", identity, sort), entry)
if err != nil {
return 146, nil, true, err
}
entries = append(entries, saved)
}
if design.TicketOnly {
grant, err = s.collection.GrantEquipmentDraw(identity)
} else {
purchase := player.GachaPurchase{Group: gamedata.GachaGroupDesign{ID: group.ID, PointCount: group.PointCount}, BuyType: buyType, Fixed: fixedStates}
if isDailyFree {
purchase.DailyKey, purchase.DailyLimit = dailyResetKey(s.now()), freeDailyLimit
}
grant, err = s.collection.GrantEquipmentPurchase(identity, uint64(len(entries)), purchase)
}
if err != nil {
return 146, nil, true, err
}
}
bundle := []byte{}
for _, entry := range entries {
bundle = wire.AppendBytes(bundle, 4, player.EquipmentWire(entry))
}
response := wire.AppendBytes(nil, 1, bundle)
response = wire.AppendVarint(response, 2, grant.GachaPoint)
for _, fixed := range grant.GachaFixed {
response = wire.AppendBytes(response, 3, gachaFixedWire(fixed))
}
if !already && s.onDraw != nil {
if err := s.onDraw(uint64(design.Count)); err != nil {
return 146, nil, true, fmt.Errorf("gacha: equipment draw mission: %w", err)
}
}
return 146, response, true, nil
}
func gachaFixedWire(fixed player.GachaFixedState) []byte {
info := wire.AppendVarint(nil, 1, fixed.FixedID)
info = wire.AppendVarint(info, 2, fixed.Type)
info = wire.AppendVarint(info, 3, fixed.Count)
// ApplySortId is signed int32: -1 must be encoded explicitly, otherwise
// proto3 defaults to slot 0 and falsely labels the first draw guaranteed.
return wire.AppendVarint(info, 4, uint64(int64(fixed.ApplySort)))
}
func costumeFixedStates(fixed gamedata.GachaFixedDesign, result gamedata.GachaFixedRoll) []player.GachaFixedState {
states := make([]player.GachaFixedState, 0, 2)
if fixed.CostumeGrade4Count != 0 {
states = append(states, player.GachaFixedState{
FixedID: fixed.ID, Type: 0, Count: result.CostumeGrade4Count, ApplySort: result.CostumeGrade4Sort,
})
}
if fixed.CostumeGrade5Count != 0 {
states = append(states, player.GachaFixedState{
FixedID: fixed.ID, Type: 1, Count: result.CostumeGrade5Count, ApplySort: result.CostumeGrade5Sort,
})
}
return states
}
func (s *Service) completeSharedFixedStates(fixedID uint64, updates []player.GachaFixedState) []player.GachaFixedState {
if fixedID == 0 || s.equipmentCatalog == nil {
return updates
}
known := false
for _, fixed := range s.equipmentCatalog.FixedDesigns() {
if fixed.ID == fixedID {
known = true
break
}
}
if !known {
return updates
}
states := make([]player.GachaFixedState, 4)
for fixedType := range states {
states[fixedType] = player.GachaFixedState{
FixedID: fixedID, Type: uint64(fixedType),
Count: s.collection.GachaFixedCount(fixedID, uint64(fixedType)), ApplySort: -1,
}
}
for _, update := range updates {
if update.FixedID != fixedID || update.Type >= uint64(len(states)) {
return updates
}
states[update.Type] = update
}
return states
}
func (s *Service) manualPointExchange(request []byte, seq uint64) (int, []byte, bool, error) {
groupID, found, err := wire.Varint(request, 2)
if err != nil || !found || groupID == 0 {
return 188, nil, true, errors.New("gacha: missing point exchange group")
}
count, found, err := wire.Varint(request, 3)
if err != nil || !found || count == 0 {
return 188, nil, true, errors.New("gacha: invalid point exchange count")
}
group, ok := s.regular.Group(groupID)
if !ok || group.PointCount == 0 {
return 188, nil, true, fmt.Errorf("gacha: group %d does not support point exchange", groupID)
}
identity := fmt.Sprintf("gacha-point-manual:%s:%d:seq:%d", s.loginIdentity(), groupID, seq)
exchange, err := s.collection.ExchangeGachaPoint(identity, groupID, count)
if err != nil {
return 188, nil, true, err
}
if _, err := s.wallet.GrantHopePowderOnce(identity, exchange.Count); err != nil {
return 188, nil, true, err
}
// Manual conversion grants account currency type 22, not a stackable
// ItemDBInfo. The client's common reward handler applies RepaidCurrency.
reward := wire.AppendVarint(nil, 2, 22)
reward = wire.AppendVarint(reward, 3, exchange.Count)
bundle := wire.AppendBytes(nil, 10, reward)
return 188, wire.AppendBytes(nil, 1, bundle), true, nil
}
func (s *Service) pointExchange(request []byte, seq uint64) (int, []byte, bool, error) {
groupID, found, err := wire.Varint(request, 2)
if err != nil || !found || groupID == 0 {
return 147, nil, true, errors.New("gacha: missing point exchange group")
}
selectedItemID, _, err := wire.Varint(request, 3)
if err != nil {
return 147, nil, true, errors.New("gacha: invalid point exchange selection")
}
if s.equipmentCatalog != nil {
if group, ok := s.equipmentCatalog.Group(groupID); ok {
return s.pointExchangeEquipment(group, selectedItemID, seq)
}
}
group, ok := s.regular.Group(groupID)
if !ok || group.GachaType != 1 || group.PickUpExchangeCost == 0 {
return 147, nil, true, fmt.Errorf("gacha: group %d does not support costume point exchange", groupID)
}
costumeID := group.PickUpCostumeID
if selectedItemID != 0 {
selections := s.collection.GachaSelections(groupID)
if len(selections) != 1 || selections[0].ItemID != selectedItemID {
return 147, nil, true, fmt.Errorf("gacha: selection %d is not the saved pickup target for group %d", selectedItemID, groupID)
}
costumeID = selectedItemID
}
if costumeID == 0 {
return 147, nil, true, fmt.Errorf("gacha: group %d has no pickup costume", groupID)
}
if _, ok := s.regular.Character(costumeID); !ok {
return 147, nil, true, fmt.Errorf("gacha: pickup costume %d has no character design", costumeID)
}
identity := fmt.Sprintf("gacha-point-costume:%s:%d:%d:seq:%d", s.loginIdentity(), groupID, costumeID, seq)
grant, err := s.collection.GrantGachaPointCostume(identity, group, costumeID, s.regular)
if err != nil {
return 147, nil, true, err
}
if err := s.creditOverflow(identity, grant); err != nil {
return 147, nil, true, err
}
return 147, wire.AppendBytes(nil, 1, s.rewardBundle(grant)), true, nil
}
func (s *Service) pointExchangeEquipment(group gamedata.EquipmentGachaGroup, selectedItemID, seq uint64) (int, []byte, bool, error) {
if s.equipmentInventory == nil {
return 147, nil, true, errors.New("gacha: equipment inventory not attached")
}
if group.ID == 0 || group.PickUpExchangeCost == 0 || group.PickUpItemID == 0 {
return 147, nil, true, fmt.Errorf("gacha: group %d does not support equipment point exchange", group.ID)
}
// Non-selection equipment banners send the proto3 default zero. Refuse a
// client-supplied item ID so the request cannot exchange points for an
// arbitrary equipment design.
if selectedItemID != 0 {
return 147, nil, true, fmt.Errorf("gacha: equipment group %d does not support pickup selection", group.ID)
}
identity := fmt.Sprintf("gacha-point-equipment:%s:%d:%d:seq:%d", s.loginIdentity(), group.ID, group.PickUpItemID, seq)
equipmentIdentity := identity + ":equip"
if _, already := s.collection.Grant(identity); already {
entry, found := s.equipmentInventory.Granted(equipmentIdentity)
if !found {
return 147, nil, true, fmt.Errorf("gacha: equipment exchange retry %s is missing its instance", identity)
}
bundle := wire.AppendBytes(nil, 4, player.EquipmentWire(entry))
return 147, wire.AppendBytes(nil, 1, bundle), true, nil
}
if s.collection.GachaUser(group.ID).Point < group.PickUpExchangeCost {
return 147, nil, true, errors.New("gacha: insufficient equipment pickup exchange point")
}
main, sub, private, err := s.equipmentCatalog.RollOptions(group.PickUpItemID)
if err != nil {
return 147, nil, true, err
}
entry := player.Equipment{ID: group.PickUpItemID, Rank: []uint64{0, 0, 0}}
for _, option := range main {
entry.MainOption = append(entry.MainOption, player.EquipmentOption{GroupID: option.GroupID, ID: option.ID})
}
for _, option := range sub {
entry.SubOption = append(entry.SubOption, player.EquipmentOption{GroupID: option.GroupID, ID: option.ID})
}
if private != nil {
entry.PrivateOption = &player.EquipmentOption{GroupID: private.GroupID, ID: private.ID}
}
saved, err := s.equipmentInventory.GrantGeneratedOnce(equipmentIdentity, entry)
if err != nil {
return 147, nil, true, err
}
if _, err := s.collection.GrantGachaPointEquipment(identity, group.ID, group.PickUpExchangeCost); err != nil {
return 147, nil, true, err
}
bundle := wire.AppendBytes(nil, 4, player.EquipmentWire(saved))
return 147, wire.AppendBytes(nil, 1, bundle), true, nil
}
func (s *Service) loginIdentity() string {
s.sessionMu.RLock()
defer s.sessionMu.RUnlock()
if s.sessionID == "" {
return "local"
}
return s.sessionID
}
func (s *Service) requestIdentity(gachaID, seq uint64) string {
s.sessionMu.RLock()
sessionID := s.sessionID
s.sessionMu.RUnlock()
if sessionID == "" {
// Unit tests and embedders without a session coordinator retain the
// original stable key. Production always supplies BeginSession.
return fmt.Sprintf("regular-gacha:%d:seq:%d", gachaID, seq)
}
return fmt.Sprintf("regular-gacha:%d:session:%s:seq:%d", gachaID, sessionID, seq)
}
func (s *Service) preview(request []byte) (int, []byte, bool, error) {
id, found, err := wire.Varint(request, 2)
if err != nil || !found {
return 0, nil, true, fmt.Errorf("gacha: unsupported preview id %d", id)
}
if s.first != nil && id == s.first.GachaID {
return s.previewFirstGacha(request)
}
if _, bought := s.collection.Grant(s.infiniteGrant()); bought {
return 175, nil, true, errors.New("gacha: infinite product already purchased")
}
if id != s.design.GachaID {
return 0, nil, true, fmt.Errorf("gacha: unsupported preview id %d", id)
}
group, found, err := wire.Varint(request, 3)
if err != nil || !found || group != s.design.ProductGroupID {
return 0, nil, true, fmt.Errorf("gacha: unsupported preview product group %d", group)
}
product, found, err := wire.Varint(request, 4)
if err != nil || !found || product != s.design.ProductID {
return 0, nil, true, fmt.Errorf("gacha: unsupported preview product %d", product)
}
if s.previewEventIndex == 0 {
return 0, nil, true, errors.New("gacha: infinite preview event is not configured")
}
roll, err := s.design.Roll()
if err != nil {
return 0, nil, true, err
}
if err := s.collection.SetPreview(s.previewEventIndex, roll); err != nil {
return 0, nil, true, err
}
if s.onPreview != nil {
if err := s.onPreview(); err != nil {
return 0, nil, true, fmt.Errorf("gacha: update preview mission: %w", err)
}
}
var bundle []byte
for i, costumeID := range roll {
costume := wire.AppendVarint(nil, 2, costumeID)
if i != 0 {
costume = wire.AppendVarint(costume, 6, uint64(i))
}
bundle = wire.AppendBytes(bundle, 3, costume)
}
return 175, wire.AppendBytes(nil, 1, bundle), true, nil
}
func (s *Service) confirm(request []byte) (int, []byte, bool, error) {
group, found, err := wire.Varint(request, 3)
if err != nil || !found {
return 0, nil, true, fmt.Errorf("gacha: unsupported cash product group %d", group)
}
var productID, saleGroup, buyCount uint64
err = wire.Walk(request, func(field wire.Field) error {
if field.Number != 4 {
return nil
}
if field.Type != 2 || productID != 0 {
return errors.New("gacha: invalid product list")
}
var parseErr error
productID, found, parseErr = wire.Varint(field.Value, 1)
if parseErr != nil || !found {
return errors.New("gacha: product id missing")
}
saleGroup, _, parseErr = wire.Varint(field.Value, 2)
if parseErr != nil {
return parseErr
}
buyCount, found, parseErr = wire.Varint(field.Value, 3)
if parseErr != nil || !found {
return errors.New("gacha: buy count missing")
}
return nil
})
if err != nil {
return 0, nil, true, err
}
if buyCount != 1 {
return 0, nil, true, fmt.Errorf("gacha: unsupported product %d sale %d count %d", productID, saleGroup, buyCount)
}
for _, cashGroup := range s.regular.Groups() {
if group != cashGroup.CashProductGroupID || productID != cashGroup.CashProductID || saleGroup != cashGroup.CashSalesGroup {
continue
}
if len(cashGroup.CashRewards) == 0 {
return 61, nil, true, errors.New("gacha: cash product rewards unavailable")
}
productGrant := fmt.Sprintf("cash-product:%d:%d", group, productID)
ticketGrant := fmt.Sprintf("cash-product-reward:%d:%d:0", group, productID)
if s.inventory == nil {
return 61, nil, true, errors.New("gacha: inventory not attached")
}
items, err := s.inventory.GrantOnce(ticketGrant, cashGroup.CashRewards)
if err != nil {
return 61, nil, true, err
}
if err := s.collection.RecordGrantMarker(productGrant); err != nil {
return 61, nil, true, err
}
if len(items) == 0 {
items = s.inventory.GrantedItems(ticketGrant)
}
var bundle []byte
for _, item := range items {
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item))
}
return 61, wire.AppendBytes(nil, 1, bundle), true, nil
}
if group != s.design.ProductGroupID || productID != s.design.ProductID || saleGroup != s.design.SaleGroup {
return 0, nil, true, fmt.Errorf("gacha: unsupported cash product group=%d product=%d", group, productID)
}
grant, err := s.collection.ConfirmInfinite(s.infiniteGrant(), s.design)
if err != nil {
return 0, nil, true, err
}
if err := s.creditOverflow(s.infiniteGrant(), grant); err != nil {
return 61, nil, true, err
}
return 61, wire.AppendBytes(nil, 1, s.rewardBundle(grant)), true, nil
}
func (s *Service) creditOverflow(identity string, grant player.CollectionGrant) error {
var mileage uint64
for _, exchange := range grant.Exchanges {
if exchange.ExchangeItemType != 20 {
return fmt.Errorf("gacha: unsupported costume overflow type %d", exchange.ExchangeItemType)
}
if ^uint64(0)-mileage < exchange.ExchangeCount {
return errors.New("gacha: costume overflow mileage overflow")
}
mileage += exchange.ExchangeCount
}
if mileage == 0 {
return nil
}
_, err := s.wallet.GrantMileageOnce(identity, mileage)
return err
}
func (s *Service) rewardBundle(grant player.CollectionGrant) []byte {
var bundle []byte
newCharacters := make(map[uint64]player.Character, len(grant.CharacterIndices))
for _, index := range grant.CharacterIndices {
if character, found := s.collection.CharacterByIndex(index); found {
newCharacters[character.ConnectPotentialCostume] = character
bundle = wire.AppendBytes(bundle, 2, player.CharacterWire(character))
}
}
for _, index := range grant.CostumeIndices {
if costume, found := s.collection.CostumeByIndex(index); found {
bundle = wire.AppendBytes(bundle, 3, player.CostumeWire(costume))
}
}
for position, costumeID := range grant.ViewCostumeIDs {
sortID := uint64(position)
if len(grant.ViewCostumeSortIDs) == len(grant.ViewCostumeIDs) {
sortID = grant.ViewCostumeSortIDs[position]
}
view := wire.AppendVarint(nil, 2, costumeID)
view = wire.AppendVarint(view, 3, 11)
view = wire.AppendVarint(view, 4, 1)
bundle = wire.AppendBytes(bundle, 6, view)
if character, found := newCharacters[costumeID]; found {
charView := wire.AppendVarint(nil, 2, character.ID)
charView = wire.AppendVarint(charView, 3, 6)
charView = wire.AppendVarint(charView, 4, 1)
if sortID != 0 {
charView = wire.AppendVarint(charView, 6, sortID)
}
bundle = wire.AppendBytes(bundle, 6, charView)
}
}
for _, upgrade := range grant.Upgrades {
info := wire.AppendVarint(nil, 1, upgrade.InvenIndex)
info = wire.AppendVarint(info, 2, 11)
info = wire.AppendVarint(info, 3, upgrade.CostumeID)
if upgrade.Before != 0 {
info = wire.AppendVarint(info, 4, upgrade.Before)
}
info = wire.AppendVarint(info, 5, upgrade.After)
if upgrade.SortID != 0 {
info = wire.AppendVarint(info, 6, upgrade.SortID)
}
bundle = wire.AppendBytes(bundle, 9, info)
}
var repaidMileage uint64
for _, exchange := range grant.Exchanges {
info := wire.AppendVarint(nil, 1, exchange.OriginalItemType)
info = wire.AppendVarint(info, 2, exchange.OriginalItemID)
info = wire.AppendVarint(info, 3, exchange.OriginalCount)
info = wire.AppendVarint(info, 4, exchange.ExchangeItemType)
if exchange.ExchangeItemID != 0 {
info = wire.AppendVarint(info, 5, exchange.ExchangeItemID)
}
info = wire.AppendVarint(info, 6, exchange.ExchangeCount)
if exchange.SortID != 0 {
info = wire.AppendVarint(info, 7, exchange.SortID)
}
bundle = wire.AppendBytes(bundle, 8, info)
if exchange.ExchangeItemType == 20 {
repaidMileage += exchange.ExchangeCount
}
}
if repaidMileage != 0 {
repaid := wire.AppendVarint(nil, 2, 20)
repaid = wire.AppendVarint(repaid, 3, repaidMileage)
bundle = wire.AppendBytes(bundle, 10, repaid)
}
return bundle
}
func (s *Service) gachaInfo() []byte {
var out []byte
_, infinitePurchased := s.collection.Grant(s.infiniteGrant())
if s.schedule != nil {
for _, entry := range s.schedule.Schedules {
// The versioned seed is the public schedule, while availability of
// the one-time infinite product is account state. Once the product
// grant exists, publishing this group makes the client reopen the
// preview flow even though the authoritative preview endpoint must
// reject a second purchase.
if infinitePurchased && entry.GroupID == s.design.GroupID {
continue
}
if special, ok := s.regular.Group(entry.GroupID); ok && special.GachaSubType == 1 && special.CashProductID != 0 {
if _, drawn := s.collection.Grant(fmt.Sprintf("special-gacha:%d:%d", special.ID, special.TenTimeGachaID)); drawn {
continue
}
}
schedule := wire.AppendVarint(nil, 1, entry.GroupID)
schedule = wire.AppendVarint(schedule, 2, entry.StartTime)
schedule = wire.AppendVarint(schedule, 3, entry.EndTime)
if entry.FreeCountBonus {
schedule = wire.AppendVarint(schedule, 4, 1)
}
if entry.CashCountBonus {
schedule = wire.AppendVarint(schedule, 5, 1)
}
out = wire.AppendBytes(out, 1, schedule)
}
for _, entry := range s.schedule.StepUps {
step := wire.AppendVarint(nil, 1, entry.GroupID)
step = wire.AppendVarint(step, 2, entry.StartTime)
step = wire.AppendVarint(step, 3, entry.EndTime)
out = wire.AppendBytes(out, 7, step)
if completed := s.collection.StepUpProgress(entry.GroupID); completed != 0 {
user := wire.AppendVarint(nil, 1, entry.GroupID)
user = wire.AppendVarint(user, 2, completed)
out = wire.AppendBytes(out, 8, user)
}
}
}
for _, selection := range s.collection.AllGachaSelections() {
info := wire.AppendVarint(nil, 1, selection.GroupID)
if selection.Slot != 0 {
info = wire.AppendVarint(info, 2, selection.Slot)
}
info = wire.AppendVarint(info, 3, selection.ItemID)
out = wire.AppendBytes(out, 5, info)
}
for _, change := range s.collection.GachaSelectionChangeCounts() {
info := wire.AppendVarint(nil, 1, change.GroupID)
info = wire.AppendVarint(info, 2, change.Count)
out = wire.AppendBytes(out, 6, info)
}
if !infinitePurchased {
preview := s.collection.LatestPreview()
eventIndex, locked := s.collection.PreviewLock()
if len(preview) == s.design.Count && eventIndex != 0 {
var bundle []byte
for sortID, costumeID := range preview {
costume := wire.AppendVarint(nil, 2, costumeID)
if sortID != 0 {
costume = wire.AppendVarint(costume, 6, uint64(sortID))
}
bundle = wire.AppendBytes(bundle, 3, costume)
}
persisted := wire.AppendVarint(nil, 1, eventIndex)
persisted = wire.AppendBytes(persisted, 8, bundle)
if locked {
persisted = wire.AppendVarint(persisted, 3, 1)
}
out = wire.AppendBytes(out, 9, persisted)
}
}
for _, user := range s.collection.GachaUsersForDay(dailyResetKey(s.now())) {
info := wire.AppendVarint(nil, 1, user.GroupID)
info = wire.AppendVarint(info, 2, user.Point)
info = wire.AppendVarint(info, 3, user.TotalBuyCount)
info = wire.AppendVarint(info, 4, user.OneFreePickCount)
info = wire.AppendVarint(info, 5, user.OneCashPickCount)
info = wire.AppendVarint(info, 6, user.TenFreePickCount)
info = wire.AppendVarint(info, 7, user.TenCashPickCount)
info = wire.AppendVarint(info, 8, user.ExchangeItemCount)
info = wire.AppendVarint(info, 9, user.ExchangeMileageCount)
out = wire.AppendBytes(out, 2, info)
}
for _, fixed := range s.collection.GachaFixedStates() {
out = wire.AppendBytes(out, 4, gachaFixedWire(fixed))
}
return out
}
// The client converts Unix time to UTC+9 and applies GameData's
// 09:00:00 DailyResetTime. That boundary is exactly 00:00 UTC. Keeping the
// key independent of the host OS timezone prevents an early reset on a
// Chinese development machine.
func dailyResetKey(now time.Time) string { return now.UTC().Format("2006-01-02") }