package service import ( "context" "fmt" "log" "time" pb "lunar-tear/server/gen/proto" "lunar-tear/server/internal/gacha" "lunar-tear/server/internal/gametime" "lunar-tear/server/internal/model" "lunar-tear/server/internal/runtime" "lunar-tear/server/internal/store" emptypb "google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/timestamppb" ) type GachaServiceServer struct { pb.UnimplementedGachaServiceServer users store.UserRepository sessions store.SessionRepository holder *runtime.Holder } func NewGachaServiceServer( users store.UserRepository, sessions store.SessionRepository, holder *runtime.Holder, ) *GachaServiceServer { return &GachaServiceServer{ users: users, sessions: sessions, holder: holder, } } func (s *GachaServiceServer) GetGachaList(ctx context.Context, req *pb.GetGachaListRequest) (*pb.GetGachaListResponse, error) { log.Printf("[GachaService] GetGachaList: labels=%v", req.GachaLabelType) cat := s.holder.Get() catalog := cat.GachaEntries handler := cat.GachaHandler userId := CurrentUserId(ctx, s.users, s.sessions) nowMillis := gametime.NowMillis() user, err := s.users.UpdateUser(userId, func(user *store.UserState) { user.EnsureMaps() autoConvertExpiredMedals(user, catalog, handler, nowMillis) }) if err != nil { return nil, fmt.Errorf("update user: %w", err) } gachaList := make([]*pb.Gacha, 0, len(catalog)) for _, entry := range catalog { if !gachaActiveAt(entry, nowMillis) { continue } if !matchesGachaLabel(req.GachaLabelType, entry.GachaLabelType) { continue } if entry.GachaLabelType == model.GachaLabelPortalCage || entry.GachaLabelType == model.GachaLabelRecycle { continue } bs := user.Gacha.BannerStates[entry.GachaId] gachaList = append(gachaList, toProtoGacha(entry, &bs)) } return &pb.GetGachaListResponse{ Gacha: gachaList, ConvertedGachaMedal: toProtoConvertedGachaMedal(user.Gacha.ConvertedGachaMedal), }, nil } func autoConvertExpiredMedals(user *store.UserState, catalog []store.GachaCatalogEntry, handler *gacha.GachaHandler, nowMillis int64) { for _, entry := range catalog { if entry.GachaMedalId == 0 || entry.EndDatetime == 0 { continue } if nowMillis < entry.EndDatetime { continue } bs, exists := user.Gacha.BannerStates[entry.GachaId] if !exists || bs.MedalCount <= 0 { continue } medalInfo, ok := handler.MedalInfo[entry.GachaId] if !ok { continue } conversionRate := medalInfo.ConversionRate if conversionRate <= 0 { conversionRate = 1 } bookmarkCount := bs.MedalCount * conversionRate user.ConsumableItems[medalInfo.ConsumableItemId] += bookmarkCount user.Gacha.ConvertedGachaMedal.ConvertedMedalPossession = append( user.Gacha.ConvertedGachaMedal.ConvertedMedalPossession, store.ConsumableItemState{ ConsumableItemId: medalInfo.ConsumableItemId, Count: bookmarkCount, }, ) originalCount := bs.MedalCount bs.MedalCount = 0 user.Gacha.BannerStates[entry.GachaId] = bs log.Printf("[GachaService] auto-converted %d medals for gacha %d -> %d bookmarks (item %d)", originalCount, entry.GachaId, bookmarkCount, medalInfo.ConsumableItemId) } } func (s *GachaServiceServer) GetGacha(ctx context.Context, req *pb.GetGachaRequest) (*pb.GetGachaResponse, error) { log.Printf("[GachaService] GetGacha: ids=%v", req.GachaId) catalog := s.holder.Get().GachaEntries nowMillis := gametime.NowMillis() userId := CurrentUserId(ctx, s.users, s.sessions) user, err := s.users.LoadUser(userId) if err != nil { return nil, fmt.Errorf("snapshot user: %w", err) } byId := make(map[int32]*pb.Gacha, len(req.GachaId)) for _, wantedId := range req.GachaId { for _, entry := range catalog { if entry.GachaId != wantedId { continue } if !gachaActiveAt(entry, nowMillis) { break } bs := user.Gacha.BannerStates[entry.GachaId] byId[wantedId] = toProtoGacha(entry, &bs) break } } return &pb.GetGachaResponse{ Gacha: byId, }, nil } func (s *GachaServiceServer) Draw(ctx context.Context, req *pb.DrawRequest) (*pb.DrawResponse, error) { log.Printf("[GachaService] Draw: gachaId=%d phaseId=%d execCount=%d", req.GachaId, req.GachaPricePhaseId, req.ExecCount) cat := s.holder.Get() entry := findCatalogEntry(cat.GachaEntries, req.GachaId) if entry == nil { return nil, fmt.Errorf("gacha %d not found", req.GachaId) } handler := cat.GachaHandler userId := CurrentUserId(ctx, s.users, s.sessions) execCount := req.ExecCount if execCount <= 0 { execCount = 1 } var drawResult *gacha.DrawResult updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) { var drawErr error drawResult, drawErr = handler.HandleDraw(user, *entry, req.GachaPricePhaseId, execCount) if drawErr != nil { log.Printf("[GachaService] Draw error: %v", drawErr) drawResult = &gacha.DrawResult{} } }) if err != nil { return nil, fmt.Errorf("update user: %w", err) } for i, item := range drawResult.Items { if bonus, ok := drawResult.BonusItems[i]; ok { log.Printf("[GachaService] drawn[%d]: type=%d id=%d rarity=%d + bonus type=%d id=%d rarity=%d", i, item.PossessionType, item.PossessionId, item.RarityType, bonus.PossessionType, bonus.PossessionId, bonus.RarityType) } else { log.Printf("[GachaService] drawn[%d]: type=%d id=%d rarity=%d", i, item.PossessionType, item.PossessionId, item.RarityType) } } gachaResults := make([]*pb.DrawGachaOddsItem, 0, len(drawResult.Items)) dupMap := make(map[int]gacha.DuplicateInfo) for _, d := range drawResult.DuplicateInfos { dupMap[d.Index] = d } bonusDupMap := make(map[int]gacha.DuplicateInfo) for _, d := range drawResult.BonusDuplicateInfos { bonusDupMap[d.Index] = d } costumePT := int32(model.PossessionTypeCostume) weaponPT := int32(model.PossessionTypeWeapon) isMaterialDraw := model.IsMaterialBanner(entry.GachaLabelType) ownedCostumes := make(map[int32]bool, len(updatedUser.Costumes)) for _, c := range updatedUser.Costumes { ownedCostumes[c.CostumeId] = true } ownedWeapons := make(map[int32]bool, len(updatedUser.Weapons)) for _, w := range updatedUser.Weapons { ownedWeapons[w.WeaponId] = true } for i, item := range drawResult.Items { isNew := !isOwnedByType(item, ownedCostumes, ownedWeapons, updatedUser) var oddsItem *pb.DrawGachaOddsItem if isMaterialDraw { oddsItem = &pb.DrawGachaOddsItem{ GachaItem: &pb.GachaItem{ PossessionType: item.PossessionType, PossessionId: item.PossessionId, Count: 1, IsNew: isNew, }, GachaItemBonus: &pb.GachaItem{}, } } else if bonus, hasBonusWeapon := drawResult.BonusItems[i]; hasBonusWeapon { oddsItem = &pb.DrawGachaOddsItem{ GachaItem: &pb.GachaItem{ PossessionType: costumePT, PossessionId: item.PossessionId, Count: 1, IsNew: isNew, }, GachaItemBonus: &pb.GachaItem{ PossessionType: weaponPT, PossessionId: bonus.PossessionId, Count: 1, IsNew: !ownedWeapons[bonus.PossessionId], }, } } else { oddsItem = &pb.DrawGachaOddsItem{ GachaItem: &pb.GachaItem{ PossessionType: item.PossessionType, PossessionId: item.PossessionId, Count: 1, IsNew: isNew, }, GachaItemBonus: &pb.GachaItem{}, } } if drawResult.MedalBonus > 0 && entry.MedalConsumableItemId != 0 { oddsItem.MedalBonus = &pb.GachaBonus{ PossessionType: int32(model.PossessionTypeConsumableItem), PossessionId: entry.MedalConsumableItemId, Count: 0, } } if dup, ok := dupMap[i]; ok { applyDuplicationBonus(oddsItem, dup) } if bdup, ok := bonusDupMap[i]; ok { applyDuplicationBonus(oddsItem, bdup) } gachaResults = append(gachaResults, oddsItem) } var bonuses []*pb.GachaBonus for _, b := range drawResult.Bonuses { bonuses = append(bonuses, &pb.GachaBonus{ PossessionType: b.PossessionType, PossessionId: b.PossessionId, Count: b.Count, }) } bs := updatedUser.Gacha.BannerStates[entry.GachaId] nextGacha := toProtoGacha(*entry, &bs) return &pb.DrawResponse{ NextGacha: nextGacha, GachaResult: gachaResults, GachaBonus: bonuses, MenuGachaBadgeInfo: []*pb.MenuGachaBadgeInfo{}, }, nil } func (s *GachaServiceServer) ResetBoxGacha(ctx context.Context, req *pb.ResetBoxGachaRequest) (*pb.ResetBoxGachaResponse, error) { log.Printf("[GachaService] ResetBoxGacha: gachaId=%d", req.GachaId) cat := s.holder.Get() entry := findCatalogEntry(cat.GachaEntries, req.GachaId) if entry == nil { return nil, fmt.Errorf("gacha %d not found", req.GachaId) } handler := cat.GachaHandler userId := CurrentUserId(ctx, s.users, s.sessions) updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) { if resetErr := handler.HandleResetBox(user, *entry); resetErr != nil { log.Printf("[GachaService] ResetBoxGacha error: %v", resetErr) } }) if err != nil { return nil, fmt.Errorf("update user: %w", err) } bs := updatedUser.Gacha.BannerStates[entry.GachaId] return &pb.ResetBoxGachaResponse{ Gacha: toProtoGacha(*entry, &bs), }, nil } func (s *GachaServiceServer) GetRewardGacha(ctx context.Context, req *emptypb.Empty) (*pb.GetRewardGachaResponse, error) { log.Printf("[GachaService] GetRewardGacha") userId := CurrentUserId(ctx, s.users, s.sessions) user, err := s.users.LoadUser(userId) if err != nil { return nil, fmt.Errorf("snapshot user: %w", err) } maxCount := s.holder.Get().GachaHandler.Config.RewardGachaDailyMaxCount if maxCount <= 0 { maxCount = model.DefaultDailyDrawLimit } todayStart := gametime.StartOfDayMillis() drawCount := user.Gacha.TodaysCurrentDrawCount if user.Gacha.LastRewardDrawDate < todayStart { drawCount = 0 } return &pb.GetRewardGachaResponse{ Available: drawCount < maxCount, TodaysCurrentDrawCount: drawCount, DailyMaxCount: maxCount, }, nil } func (s *GachaServiceServer) RewardDraw(ctx context.Context, req *pb.RewardDrawRequest) (*pb.RewardDrawResponse, error) { log.Printf("[GachaService] RewardDraw: placement=%q reward=%q amount=%q", req.PlacementName, req.RewardName, req.RewardAmount) userId := CurrentUserId(ctx, s.users, s.sessions) handler := s.holder.Get().GachaHandler var items []gacha.DrawnItem updatedUser, err := s.users.UpdateUser(userId, func(user *store.UserState) { var drawErr error items, drawErr = handler.HandleRewardDraw(user, 1) if drawErr != nil { log.Printf("[GachaService] RewardDraw error: %v", drawErr) } }) if err != nil { return nil, fmt.Errorf("update user: %w", err) } ownedCostumes := make(map[int32]bool, len(updatedUser.Costumes)) for _, c := range updatedUser.Costumes { ownedCostumes[c.CostumeId] = true } ownedWeapons := make(map[int32]bool, len(updatedUser.Weapons)) for _, w := range updatedUser.Weapons { ownedWeapons[w.WeaponId] = true } results := make([]*pb.RewardGachaItem, 0, len(items)) for _, item := range items { results = append(results, &pb.RewardGachaItem{ PossessionType: item.PossessionType, PossessionId: item.PossessionId, Count: 1, IsNew: !isOwnedByType(item, ownedCostumes, ownedWeapons, updatedUser), }) } return &pb.RewardDrawResponse{ RewardGachaResult: results, }, nil } func findCatalogEntry(catalog []store.GachaCatalogEntry, gachaId int32) *store.GachaCatalogEntry { for i := range catalog { if catalog[i].GachaId == gachaId { return &catalog[i] } } return nil } func matchesGachaLabel(labels []int32, label int32) bool { if len(labels) == 0 { return true } for _, candidate := range labels { if candidate == label { return true } } return false } func gachaActiveAt(entry store.GachaCatalogEntry, nowMillis int64) bool { if entry.StartDatetime != 0 && nowMillis < entry.StartDatetime { return false } if entry.EndDatetime != 0 && nowMillis >= entry.EndDatetime { return false } return true } func toProtoGacha(entry store.GachaCatalogEntry, bs *store.GachaBannerState) *pb.Gacha { g := &pb.Gacha{ GachaId: entry.GachaId, GachaLabelType: entry.GachaLabelType, GachaModeType: entry.GachaModeType, GachaAutoResetType: entry.GachaAutoResetType, GachaAutoResetPeriod: entry.GachaAutoResetPeriod, NextAutoResetDatetime: safeTimestamp(entry.NextAutoResetDatetime), GachaUnlockCondition: []*pb.GachaUnlockCondition{{GachaUnlockConditionType: model.GachaUnlockNone, ConditionValue: 0}}, IsUserGachaUnlock: entry.IsUserGachaUnlock, StartDatetime: safeTimestamp(entry.StartDatetime), EndDatetime: safeTimestamp(entry.EndDatetime), RelatedMainQuestChapterId: entry.RelatedMainQuestChapterId, RelatedEventQuestChapterId: entry.RelatedEventQuestChapterId, PromotionMovieAssetId: entry.PromotionMovieAssetId, GachaMedalId: entry.GachaMedalId, GachaDecorationType: entry.GachaDecorationType, SortOrder: entry.SortOrder, IsInactive: entry.IsInactive, InformationId: entry.InformationId, } g.GachaPricePhase = buildProtoPricePhases(entry, bs) promotionItems := buildProtoPromotionItems(entry) switch entry.GachaModeType { case model.GachaModeBox: boxNumber := int32(1) if bs != nil && bs.BoxNumber > 0 { boxNumber = bs.BoxNumber } phaseId := int32(0) if len(entry.PricePhases) > 0 { phaseId = entry.PricePhases[0].PhaseId } g.GachaMode = &pb.Gacha_GachaModeBoxComposition{ GachaModeBoxComposition: &pb.GachaModeBoxComposition{ GachaBoxGroupId: entry.GroupId, BoxNumber: boxNumber, CurrentBoxNumber: boxNumber, NaviCharacterCommentAssetName: "production", GachaAssetName: entry.BannerAssetName, GachaPricePhaseId: phaseId, PromotionGachaOddsItem: promotionItems, GachaDescriptionTextId: entry.DescriptionTextId, }, } case model.GachaModeStepup: stepNumber := int32(1) loopCount := int32(0) if bs != nil { if bs.StepNumber > 0 { stepNumber = bs.StepNumber } loopCount = bs.LoopCount } g.GachaMode = &pb.Gacha_GachaModeStepupComposition{ GachaModeStepupComposition: &pb.GachaModeStepupComposition{ GachaStepGroupId: entry.GroupId, StepNumber: 1, CurrentStepNumber: stepNumber, NaviCharacterCommentAssetName: "production", GachaAssetName: entry.BannerAssetName, PromotionGachaOddsItem: promotionItems, CurrentLoopCount: loopCount, }, } default: g.GachaMode = &pb.Gacha_GachaModeBasic{ GachaModeBasic: &pb.GachaModeBasic{ NaviCharacterCommentAssetName: "production", GachaAssetName: entry.BannerAssetName, PromotionGachaOddsItem: promotionItems, }, } } return g } func buildProtoPricePhases(entry store.GachaCatalogEntry, bs *store.GachaBannerState) []*pb.GachaPricePhase { phases := make([]*pb.GachaPricePhase, 0, len(entry.PricePhases)) for _, p := range entry.PricePhases { isEnabled := true if entry.GachaModeType == model.GachaModeStepup && bs != nil { currentStep := bs.StepNumber if currentStep <= 0 { currentStep = 1 } isEnabled = p.StepNumber == currentStep } var bonuses []*pb.GachaBonus for _, b := range p.Bonuses { bonuses = append(bonuses, &pb.GachaBonus{ PossessionType: b.PossessionType, PossessionId: b.PossessionId, Count: b.Count, }) } limitExec := p.LimitExecCount if limitExec <= 0 { limitExec = 999 } phases = append(phases, &pb.GachaPricePhase{ GachaPricePhaseId: p.PhaseId, IsEnabled: isEnabled, EndDatetime: safeTimestamp(entry.EndDatetime), PriceType: p.PriceType, PriceId: p.PriceId, Price: p.Price, RegularPrice: p.RegularPrice, DrawCount: p.DrawCount, LimitExecCount: limitExec, EachMaxExecCount: p.DrawCount, GachaBonus: bonuses, GachaOddsFixedRarity: &pb.GachaOddsFixedRarity{ FixedRarityTypeLowerLimit: p.FixedRarityMin, FixedCount: p.FixedCount, }, GachaBadgeType: model.GachaBadgeTypeNone, }) } return phases } func buildProtoPromotionItems(entry store.GachaCatalogEntry) []*pb.GachaOddsItem { if len(entry.PromotionItems) == 0 { return nil } isMaterial := model.IsMaterialBanner(entry.GachaLabelType) items := make([]*pb.GachaOddsItem, 0, len(entry.PromotionItems)) for i, pi := range entry.PromotionItems { bonus := &pb.GachaItem{} if !isMaterial && pi.BonusPossessionType != 0 { bonus = &pb.GachaItem{ PossessionType: pi.BonusPossessionType, PossessionId: pi.BonusPossessionId, Count: 1, } } items = append(items, &pb.GachaOddsItem{ GachaItem: &pb.GachaItem{ PossessionType: pi.PossessionType, PossessionId: pi.PossessionId, Count: 1, PromotionOrder: int32(i + 1), }, GachaItemBonus: bonus, MaxDrawableCount: 999, IsTarget: pi.IsTarget, }) } return items } func toProtoConvertedGachaMedal(state store.ConvertedGachaMedalState) *pb.ConvertedGachaMedal { items := make([]*pb.ConsumableItemPossession, 0, len(state.ConvertedMedalPossession)) for _, item := range state.ConvertedMedalPossession { items = append(items, &pb.ConsumableItemPossession{ ConsumableItemId: item.ConsumableItemId, Count: item.Count, }) } obtain := &pb.ConsumableItemPossession{ ConsumableItemId: 0, Count: 0, } if state.ObtainPossession != nil { obtain.ConsumableItemId = state.ObtainPossession.ConsumableItemId obtain.Count = state.ObtainPossession.Count } return &pb.ConvertedGachaMedal{ ConvertedMedalPossession: items, ObtainPossession: obtain, } } func safeTimestamp(unixMillis int64) *timestamppb.Timestamp { if unixMillis == 0 { return ×tamppb.Timestamp{Seconds: 0} } return timestamppb.New(time.UnixMilli(unixMillis)) } func applyDuplicationBonus(oddsItem *pb.DrawGachaOddsItem, dup gacha.DuplicateInfo) { if oddsItem.DuplicationBonusGrade == 0 { oddsItem.DuplicationBonusGrade = dup.Grade } for _, b := range dup.Bonuses { oddsItem.DuplicationBonus = append(oddsItem.DuplicationBonus, &pb.GachaBonus{ PossessionType: b.PossessionType, PossessionId: b.PossessionId, Count: b.Count, }) } } func isOwnedByType(item gacha.DrawnItem, costumes, weapons map[int32]bool, user store.UserState) bool { switch item.PossessionType { case int32(model.PossessionTypeCostume): return costumes[item.PossessionId] case int32(model.PossessionTypeWeapon): return weapons[item.PossessionId] case int32(model.PossessionTypeMaterial): return user.Materials[item.PossessionId] > 0 case int32(model.PossessionTypeWeaponEnhanced): return user.ConsumableItems[item.PossessionId] > 0 } return false }