package player import ( "errors" "fmt" "math" "sort" "strconv" "strings" ) func applyStepUpProgress(next *collectionSnapshot, groupID, step uint64) error { if groupID == 0 && step == 0 { return nil } if groupID == 0 || step == 0 { return errors.New("player: incomplete step-up purchase") } key := strconv.FormatUint(groupID, 10) completed := next.StepUpProgress[key] if completed >= step { return nil } if completed+1 != step { return fmt.Errorf("player: step-up group %d expects step %d, got %d", groupID, completed+1, step) } next.StepUpProgress[key] = step return nil } func gachaFixedKey(fixedID, fixedType uint64) string { return fmt.Sprintf("%d:%d", fixedID, fixedType) } func dailyGachaPrefix(day string, groupID, buyType uint64) string { return fmt.Sprintf("daily-gacha:%s:%d:%d:", day, groupID, buyType) } func applyGachaPurchase(next *collectionSnapshot, identity string, costumeIDs []uint64, purchase GachaPurchase, grant *CollectionGrant) error { if next.GachaApplied[identity] { return nil } groupKey := strconv.FormatUint(purchase.Group.ID, 10) user := next.GachaUsers[groupKey] user.GroupID = purchase.Group.ID if purchase.DailyLimit != 0 { if purchase.DailyKey == "" || (purchase.BuyType != 0 && purchase.BuyType != 2) { return errors.New("player: invalid daily gacha purchase") } prefix := dailyGachaPrefix(purchase.DailyKey, purchase.Group.ID, purchase.BuyType) var used uint64 for key, applied := range next.GachaApplied { if applied && strings.HasPrefix(key, prefix) { used++ } } if used >= purchase.DailyLimit { return fmt.Errorf("player: daily gacha group %d buy type %d exhausted", purchase.Group.ID, purchase.BuyType) } next.GachaApplied[prefix+identity] = true } rewardCount := uint64(len(costumeIDs)) if purchase.RewardCount != 0 { rewardCount = purchase.RewardCount } point := purchase.Group.PointCount * rewardCount user.Point += point switch purchase.BuyType { case 1: // GB_NORMAL user.TotalBuyCount += rewardCount case 2: // GB_CASH outside the date-scoped daily discount if purchase.DailyLimit == 0 { if rewardCount == 1 { user.OneCashPickCount++ } else { user.TenCashPickCount++ } } case 3: // GB_CASH_CONTENT_TICKET user.TotalBuyCount += rewardCount } next.GachaUsers[groupKey] = user grant.GachaGroupID = purchase.Group.ID grant.GachaPoint = point grant.GachaFixed = append([]GachaFixedState(nil), purchase.Fixed...) grant.SelectionApplySortIDs = append([]uint64(nil), purchase.SelectionApplySortIDs...) for _, fixed := range purchase.Fixed { stored := fixed stored.ApplySort = -1 next.GachaFixed[gachaFixedKey(fixed.FixedID, fixed.Type)] = stored } next.GachaApplied[identity] = true return nil } func (s *CollectionStore) GachaUsers() []GachaUserState { s.mu.Lock() defer s.mu.Unlock() result := make([]GachaUserState, 0, len(s.data.GachaUsers)) for _, value := range s.data.GachaUsers { result = append(result, value) } sort.Slice(result, func(i, j int) bool { return result[i].GroupID < result[j].GroupID }) return result } // GachaUsersForDay returns cumulative gacha state with daily free/paid counts // rebuilt from durable, date-scoped purchase markers. Historical counts never // leak into a new reset day and no storage migration is required. func (s *CollectionStore) GachaUsersForDay(day string) []GachaUserState { s.mu.Lock() defer s.mu.Unlock() byGroup := make(map[uint64]GachaUserState, len(s.data.GachaUsers)) for _, value := range s.data.GachaUsers { value.OneFreePickCount = 0 value.OneCashPickCount = 0 value.TenFreePickCount = 0 value.TenCashPickCount = 0 byGroup[value.GroupID] = value } prefix := "daily-gacha:" + day + ":" for key, applied := range s.data.GachaApplied { if !applied || !strings.HasPrefix(key, prefix) { continue } parts := strings.SplitN(strings.TrimPrefix(key, prefix), ":", 3) if len(parts) != 3 { continue } groupID, groupErr := strconv.ParseUint(parts[0], 10, 64) buyType, typeErr := strconv.ParseUint(parts[1], 10, 64) if groupErr != nil || typeErr != nil || groupID == 0 { continue } user := byGroup[groupID] user.GroupID = groupID switch buyType { case 0: user.OneFreePickCount++ case 2: user.OneCashPickCount++ } byGroup[groupID] = user } result := make([]GachaUserState, 0, len(byGroup)) for _, value := range byGroup { result = append(result, value) } sort.Slice(result, func(i, j int) bool { return result[i].GroupID < result[j].GroupID }) return result } func (s *CollectionStore) GachaDailyCount(day string, groupID, buyType uint64) uint64 { s.mu.Lock() defer s.mu.Unlock() prefix := dailyGachaPrefix(day, groupID, buyType) var count uint64 for key, applied := range s.data.GachaApplied { if applied && strings.HasPrefix(key, prefix) { count++ } } return count } func (s *CollectionStore) GachaUser(groupID uint64) GachaUserState { s.mu.Lock() defer s.mu.Unlock() return s.data.GachaUsers[strconv.FormatUint(groupID, 10)] } func (s *CollectionStore) GachaFixedStates() []GachaFixedState { s.mu.Lock() defer s.mu.Unlock() result := make([]GachaFixedState, 0, len(s.data.GachaFixed)) for _, value := range s.data.GachaFixed { value.ApplySort = -1 result = append(result, value) } sort.Slice(result, func(i, j int) bool { if result[i].FixedID != result[j].FixedID { return result[i].FixedID < result[j].FixedID } return result[i].Type < result[j].Type }) return result } func (s *CollectionStore) GachaFixedCount(fixedID, fixedType uint64) uint64 { s.mu.Lock() defer s.mu.Unlock() return s.data.GachaFixed[gachaFixedKey(fixedID, fixedType)].Count } // ExchangeGachaPoint performs the authoritative point debit. The exchange is // saved before the wallet credit; a retry can therefore safely recover a // wallet write using the same identity without spending the points twice. func (s *CollectionStore) ExchangeGachaPoint(identity string, groupID, count uint64) (GachaPointExchange, error) { if identity == "" || groupID == 0 || count == 0 { return GachaPointExchange{}, errors.New("player: invalid gacha point exchange") } s.mu.Lock() defer s.mu.Unlock() if exchange, ok := s.data.GachaPointExchange[identity]; ok { if exchange.GroupID != groupID || exchange.Count != count { return GachaPointExchange{}, errors.New("player: gacha point exchange identity conflict") } return exchange, nil } key := strconv.FormatUint(groupID, 10) user, ok := s.data.GachaUsers[key] if !ok || user.Point < count { return GachaPointExchange{}, errors.New("player: insufficient gacha point") } next := cloneCollection(s.data) user.Point -= count if math.MaxUint64-user.ExchangeMileageCount < count { return GachaPointExchange{}, errors.New("player: gacha point exchange count overflow") } user.ExchangeMileageCount += count next.GachaUsers[key] = user exchange := GachaPointExchange{GroupID: groupID, Count: count} next.GachaPointExchange[identity] = exchange if err := s.commit(next); err != nil { return GachaPointExchange{}, err } return exchange, nil }